hoardable 0.2.0 → 0.5.0

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: c960d95a373942d9183464cc50b7c4213c25c7563fa9d920c81d2c325ea71705
4
- data.tar.gz: e7e0f289e60acd720538edc15584209ed3d30e08b21758d06884a5486f198ee0
3
+ metadata.gz: 4c4bc9bc4cdd73263a6afe5551b59f085cbf7dc3877e472df8abdba62f5fcbd6
4
+ data.tar.gz: 7aa8ea828b2f6083a80c93e34a1fcc5756508db15f2a317dc95bfea2ae8b1e28
5
5
  SHA512:
6
- metadata.gz: a83966223151922d24bb009ac50465723388ba91825e27cf933bb5e00370c270a0518ec828358f1109a040e2a9202cee7b37d7ea98e0387b30dc07a74f7eb851
7
- data.tar.gz: 6bc246f6664b61c5000ffa71b24c8ddd30c409eda87e790b0ea1bd9d47f46c4e85e0b5eb89736939a75018e272cdd5ac2ce1217c7c7e9272ddc4f96cc8cc33f4
6
+ metadata.gz: 915cba36e937b34667b2ad31ae8dd780224a417669a6bb3c1abdfb2c8a19c10525e7d29a0a4f264aa475362f9b823104bd5a40d71bfc01848db2eb6a463f7c1d
7
+ data.tar.gz: ab0faaab94b03c5b6452b7aad505e16b4bb98538ae8649cfd65e17d397e3d917e6e61c752ed39b820dc30d72503e993ec5d6fbe353391c29025759a4bedeff74
data/.rubocop.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 2.6
3
3
  NewCops: enable
4
+ SuggestExtensions: false
4
5
 
5
6
  Layout/LineLength:
6
7
  Max: 120
@@ -8,3 +9,6 @@ Layout/LineLength:
8
9
  Metrics/ClassLength:
9
10
  Exclude:
10
11
  - 'test/**/*.rb'
12
+
13
+ Style/DocumentDynamicEvalDefinition:
14
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2022-09-25
4
+
5
+ - **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
6
+ it's own temporal timespan. This simplifies the ability to query versions temporarily for when
7
+ they were trashed or not. This changes, but corrects, temporal query results using `.at`.
8
+
9
+ - **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
10
+ you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
11
+ executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
12
+ 'insert';`.
13
+
14
+ ## [0.4.0] - 2022-09-24
15
+
16
+ - **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
17
+ source model’s table.
18
+
3
19
  ## [0.1.0] - 2022-07-23
4
20
 
5
21
  - Initial release
data/README.md CHANGED
@@ -69,7 +69,7 @@ Rails 7.
69
69
  ### Overview
70
70
 
71
71
  Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
72
- of that model. As we continue our example above, :
72
+ of that model. As we continue our example from above:
73
73
 
74
74
  ```
75
75
  $ irb
@@ -79,8 +79,8 @@ $ irb
79
79
  => PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, _data: jsonb, _during: tsrange, post_id: integer)
80
80
  ```
81
81
 
82
- A `Post` now `has_many :versions`. Whenever an update and deletion of a `Post` occurs, a version is
83
- created (by default):
82
+ A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion
83
+ of a `Post` occurs, a version is created:
84
84
 
85
85
  ```ruby
86
86
  post = Post.create!(title: "Title")
@@ -97,9 +97,29 @@ Post.find(post.id) # raises ActiveRecord::RecordNotFound
97
97
  Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
98
98
  `Post` has, but as a read-only record.
99
99
 
100
- If you ever need to revert to a specific version, you can call `version.revert!` on it. If you would
101
- like to untrash a specific version, you can call `version.untrash!` on it. This will re-insert the
102
- model in the parent class’ table with it’s original primary key.
100
+ If you ever need to revert to a specific version, you can call `version.revert!` on it.
101
+
102
+ ``` ruby
103
+ post = Post.create!(title: "Title")
104
+ post.update!(title: "Whoops")
105
+ post.versions.last.revert!
106
+ post.title # => "Title"
107
+ ```
108
+
109
+ If you would like to untrash a specific version, you can call `version.untrash!` on it. This will
110
+ re-insert the model in the parent class’s table with it’s original primary key.
111
+
112
+ ```ruby
113
+ post = Post.create!(title: "Title")
114
+ post.id # => 1
115
+ post.destroy!
116
+ post.versions.size # => 1
117
+ Post.find(post.id) # raises ActiveRecord::RecordNotFound
118
+ trashed_post = post.versions.trashed.last
119
+ trashed_post.id # => 2
120
+ trashed_post.untrash!
121
+ Post.find(post.id) # #<Post>
122
+ ```
103
123
 
104
124
  ### Querying and Temporal Lookup
105
125
 
@@ -112,20 +132,30 @@ post.versions.where(user_id: Current.user.id, body: "Cool!")
112
132
  If you want to look-up the version of a record at a specific time, you can use the `.at` method:
113
133
 
114
134
  ```ruby
115
- post.at(1.day.ago) # => #<PostVersion:0x000000010d44fa30>
116
- # or
117
- PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion:0x000000010d44fa30>
135
+ post.at(1.day.ago) # => #<PostVersion>
136
+ # or you can use the scope on the version model class
137
+ PostVersion.at(1.day.ago).find_by(post_id: post.id) # => #<PostVersion>
138
+ ```
139
+
140
+ The source model class also has an `.at` method:
141
+
142
+ ``` ruby
143
+ Post.at(1.day.ago) # => [#<Post>, #<Post>]
118
144
  ```
119
145
 
120
- _Note:_ A `Version` is not created upon initial parent model creation. If you would like to
121
- accurately capture the valid temporal frame of the first version, make sure your model’s table has a
122
- `created_at` timestamp field.
146
+ This will return an ActiveRecord scoped query of all `Posts` and `PostVersions` that were valid at
147
+ that time, all cast as instances of `Post`.
123
148
 
124
- By default, `hoardable` will keep copies of records you have destroyed. You can query for them as
125
- well:
149
+ _Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
150
+ beginning of the first temporal period, you will need to ensure the source model table has a
151
+ `created_at` timestamp column.
152
+
153
+ By default, `hoardable` will keep copies of records you have destroyed. You can query them
154
+ specifically with:
126
155
 
127
156
  ```ruby
128
157
  PostVersion.trashed
158
+ Post.version_class.trashed # <- same thing as above
129
159
  ```
130
160
 
131
161
  _Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
@@ -146,7 +176,10 @@ choosing.
146
176
  One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
147
177
 
148
178
  ```ruby
179
+ # config/initializers/hoardable.rb
149
180
  Hoardable.whodunit = -> { Current.user&.id }
181
+
182
+ # somewhere in your app code
150
183
  Current.user = User.find(123)
151
184
  post.update!(status: 'live')
152
185
  post.versions.last.hoardable_whodunit # => 123
@@ -174,7 +207,7 @@ class ApplicationController < ActionController::Base
174
207
  Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
175
208
  yield
176
209
  end
177
- # `Hoardable.whodunit` is back to nil or the previously set value
210
+ # `Hoardable.whodunit` and `Hoardable.meta` are back to nil or their previously set values
178
211
  end
179
212
  end
180
213
  ```
@@ -224,12 +257,13 @@ end
224
257
 
225
258
  ### Configuration
226
259
 
227
- There are three configurable options currently:
260
+ The configurable options are:
228
261
 
229
262
  ```ruby
230
263
  Hoardable.enabled # => default true
231
264
  Hoardable.version_updates # => default true
232
265
  Hoardable.save_trash # => default true
266
+ Hoardable.return_everything # => default false
233
267
  ```
234
268
 
235
269
  `Hoardable.enabled` controls whether versions will be ever be created.
@@ -239,6 +273,10 @@ Hoardable.save_trash # => default true
239
273
  `Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
240
274
  `false`, all versions of a record will be deleted when the record is destroyed.
241
275
 
276
+ `Hoardable.return_everything` controls whether to include versions when doing queries for source
277
+ models. This is typically only useful to set around a block, as explained below in
278
+ [Relationships](#relationships).
279
+
242
280
  If you would like to temporarily set a config setting, you can use `Hoardable.with`:
243
281
 
244
282
  ```ruby
@@ -247,37 +285,49 @@ Hoardable.with(enabled: false) do
247
285
  end
248
286
  ```
249
287
 
250
- You can also configure these variables per `ActiveRecord` class as well using `hoardable_options`:
288
+ You can also configure these variables per `ActiveRecord` class as well using `hoardable_config`:
251
289
 
252
290
  ```ruby
253
291
  class Comment < ActiveRecord::Base
254
292
  include Hoardable::Model
255
- hoardable_options version_updates: false
293
+ hoardable_config version_updates: false
294
+ end
295
+ ```
296
+
297
+ If you want to temporarily set the `hoardable_config` for a specific model, you can use
298
+ `with_hoardable_config`:
299
+
300
+ ```ruby
301
+ Comment.with_hoardable_config(version_updates: true) do
302
+ comment.update!(text: "Edited")
256
303
  end
257
304
  ```
258
305
 
259
- If either the model-level option or global option for a configuration variable is set to `false`,
260
- that behavior will be disabled.
306
+ If a model-level option exists, it will use that. Otherwise, it will fall back to the global
307
+ `Hoardable` config.
261
308
 
262
309
  ### Relationships
263
310
 
264
- As in life, sometimes relationships can be hard. `hoardable` is still working out best practices and
265
- features in this area, but here are a couple pointers.
311
+ As in life, sometimes relationships can be hard, but here are some pointers on handling associations
312
+ with `Hoardable` considerations.
266
313
 
267
- Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child record’s
268
- foreign key will point to the non-existent trashed version of the parent. If you would like this
269
- `belongs_to` relationship to always resolve to the parent as if it was not trashed, you can include
270
- the scope on the relationship definition:
314
+ Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
315
+ record’s foreign key will point to the non-existent trashed version of the parent. If you would like
316
+ to have `belongs_to` resolve to the trashed parent model in this case, you can use
317
+ `belongs_to_trashable` in place of `belongs_to`:
271
318
 
272
319
  ```ruby
273
- belongs_to :parent, -> { include_versions }
320
+ class Comment
321
+ include Hoardable::Associations # <- This includes is not required if this model already includes `Hoardable::Model`
322
+ belongs_to_trashable :post, -> { where(status: 'published') }, class_name: 'Article' # <- Accepts normal `belongs_to` arguments
323
+ end
274
324
  ```
275
325
 
276
326
  Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and both the parent
277
327
  and child model classes include `Hoardable::Model`. Whenever a hoardable version is created in a
278
328
  database transaction, it will create or re-use a unique event UUID for that transaction and tag all
279
- versions created with it. That way, when you `untrash!` a parent object, you can find and `untrash!`
280
- the children like so:
329
+ versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
330
+ records that were trashed with it:
281
331
 
282
332
  ```ruby
283
333
  class Post < ActiveRecord::Base
@@ -295,20 +345,36 @@ class Post < ActiveRecord::Base
295
345
  end
296
346
  ```
297
347
 
348
+ If there are models that might be related to versions that are trashed or otherwise, and/or might
349
+ trashed themselves, you can bypass the inherited tables query handling altogether by using the
350
+ `return_everything` configuration variable in `Hoardable.with`. This will ensure that you always see
351
+ all records, including update and trashed versions.
352
+
353
+ ```ruby
354
+ post.destroy!
355
+
356
+ Hoardable.with(return_everything: true) do
357
+ post = Post.find(post.id) # returns the trashed post as if it was not
358
+ post.comments # returns the trashed comments as well
359
+ end
360
+
361
+ post.reload # raises ActiveRecord::RecordNotFound
362
+ ```
363
+
298
364
  ## Gem Comparison
299
365
 
300
- ### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
366
+ ### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
301
367
 
302
368
  `paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
303
- database types than PostgeSQL and (by default) stores all versions of all versioned models in a
304
- single `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
305
- efficiently query the `versions` table, a `jsonb` column should be used, which takes up a lot of
306
- space to index. Unless you customize your configuration, all `versions` for all models types are
307
- in the same table which is inefficient if you are only interested in querying versions of a single
308
- model. By contrast, `hoardable` stores versions in smaller, isolated and inherited tables with the
309
- same database columns as their parents, which are more efficient for querying as well as auditing
310
- for truncating and dropping. The concept of a `temporal` time-frame does not exist for a single
311
- version since there is only a `created_at` timestamp.
369
+ database types than PostgeSQL and (by default) stores all versions of all versioned models in a
370
+ single `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
371
+ efficiently query the `versions` table, a `jsonb` column should be used, which takes up a lot of
372
+ space to index. Unless you customize your configuration, all `versions` for all models types are
373
+ in the same table which is inefficient if you are only interested in querying versions of a single
374
+ model. By contrast, `hoardable` stores versions in smaller, isolated and inherited tables with the
375
+ same database columns as their parents, which are more efficient for querying as well as auditing
376
+ for truncating and dropping. The concept of a `temporal` time-frame does not exist for a single
377
+ version since there is only a `created_at` timestamp.
312
378
 
313
379
  ### [`audited`](https://github.com/collectiveidea/audited)
314
380
 
@@ -2,7 +2,7 @@
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
+ create_enum :hoardable_operation, %w[update delete insert]
6
6
  create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
7
7
  t.jsonb :_data
8
8
  t.tsrange :_during, null: false
@@ -11,7 +11,7 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
11
11
  SELECT 1 FROM pg_type t
12
12
  WHERE t.typname = 'hoardable_operation'
13
13
  ) THEN
14
- CREATE TYPE hoardable_operation AS ENUM ('update', 'delete');
14
+ CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
15
15
  END IF;
16
16
  END
17
17
  $$;
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # This concern contains +ActiveRecord+ association considerations for {SourceModel}. It is
5
+ # included by {Model} but can be included on it’s own for models that +belongs_to+ a Hoardable
6
+ # {Model}.
7
+ module Associations
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # A wrapper for +ActiveRecord+’s +belongs_to+ that allows for falling back to the most recent
12
+ # trashed +version+, in the case that the related source has been trashed.
13
+ def belongs_to_trashable(name, scope = nil, **options)
14
+ belongs_to(name, scope, **options)
15
+
16
+ trashable_relationship_name = "trashable_#{name}"
17
+
18
+ define_method(trashable_relationship_name) do
19
+ source_reflection = self.class.reflections[name.to_s]
20
+ version_class = source_reflection.klass.version_class
21
+ version_class.trashed.only_most_recent.find_by(
22
+ version_class.hoardable_source_foreign_key => source_reflection.foreign_key
23
+ )
24
+ end
25
+
26
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
27
+ def #{name}
28
+ super || #{trashable_relationship_name}
29
+ end
30
+ RUBY
31
+ end
32
+ end
33
+ end
34
+ end
@@ -5,22 +5,23 @@ module Hoardable
5
5
  # Symbols for use with setting contextual data, when creating versions. See
6
6
  # {file:README.md#tracking-contextual-data README} for more.
7
7
  DATA_KEYS = %i[meta whodunit note event_uuid].freeze
8
+
8
9
  # Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
9
10
  # README} for more.
10
- CONFIG_KEYS = %i[enabled version_updates save_trash].freeze
11
+ CONFIG_KEYS = %i[enabled version_updates save_trash return_everything].freeze
11
12
 
12
- # @!visibility private
13
13
  VERSION_CLASS_SUFFIX = 'Version'
14
+ private_constant :VERSION_CLASS_SUFFIX
14
15
 
15
- # @!visibility private
16
16
  VERSION_TABLE_SUFFIX = "_#{VERSION_CLASS_SUFFIX.tableize}"
17
+ private_constant :VERSION_TABLE_SUFFIX
17
18
 
18
- # @!visibility private
19
19
  DURING_QUERY = '_during @> ?::timestamp'
20
+ private_constant :DURING_QUERY
20
21
 
21
22
  @context = {}
22
23
  @config = CONFIG_KEYS.to_h do |key|
23
- [key, true]
24
+ [key, key != :return_everything]
24
25
  end
25
26
 
26
27
  class << self
@@ -44,9 +45,10 @@ module Hoardable
44
45
  end
45
46
  end
46
47
 
47
- # This is a general use method for setting {DATA_KEYS} or {CONFIG_KEYS} around a scoped block.
48
+ # This is a general use method for setting {file:README.md#tracking-contextual-data Contextual
49
+ # Data} or {file:README.md#configuration Configuration} around a block.
48
50
  #
49
- # @param hash [Hash] Options and contextual data to set within a block
51
+ # @param hash [Hash] config and contextual data to set within a block
50
52
  def with(hash)
51
53
  current_config = @config
52
54
  current_context = @context
@@ -10,7 +10,7 @@ module Hoardable
10
10
 
11
11
  class_methods do
12
12
  # @!visibility private
13
- attr_reader :_hoardable_options
13
+ attr_reader :_hoardable_config
14
14
 
15
15
  # If called with a hash, this will set the model-level +Hoardable+ configuration variables. If
16
16
  # called without an argument it will return the computed +Hoardable+ configuration considering
@@ -19,19 +19,33 @@ module Hoardable
19
19
  # @param hash [Hash] The +Hoardable+ configuration for the model. Keys must be present in
20
20
  # {CONFIG_KEYS}
21
21
  # @return [Hash]
22
- def hoardable_options(hash = nil)
22
+ def hoardable_config(hash = nil)
23
23
  if hash
24
- @_hoardable_options = hash.slice(*Hoardable::CONFIG_KEYS)
24
+ @_hoardable_config = hash.slice(*CONFIG_KEYS)
25
25
  else
26
- @_hoardable_options ||= {}
27
- Hoardable::CONFIG_KEYS.to_h do |key|
28
- [key, Hoardable.send(key) != false && @_hoardable_options[key] != false]
26
+ @_hoardable_config ||= {}
27
+ CONFIG_KEYS.to_h do |key|
28
+ [key, @_hoardable_config.key?(key) ? @_hoardable_config[key] : Hoardable.send(key)]
29
29
  end
30
30
  end
31
31
  end
32
+
33
+ # Set the model-level +Hoardable+ configuration variables around a block. The configuration
34
+ # will be reset to it’s previous value afterwards.
35
+ #
36
+ # @param hash [Hash] The +Hoardable+ configuration for the model. Keys must be present in
37
+ # {CONFIG_KEYS}
38
+ def with_hoardable_config(hash)
39
+ current_config = @_hoardable_config
40
+ @_hoardable_config = hash.slice(*CONFIG_KEYS)
41
+ yield
42
+ ensure
43
+ @_hoardable_config = current_config
44
+ end
32
45
  end
33
46
 
34
47
  included do
48
+ include Associations
35
49
  define_model_callbacks :versioned
36
50
  define_model_callbacks :reverted, only: :after
37
51
  define_model_callbacks :untrashed, only: :after
@@ -34,18 +34,33 @@ module Hoardable
34
34
 
35
35
  # Returns all +versions+ in ascending order of their temporal timeframes.
36
36
  has_many(
37
- :versions, -> { order(:_during) },
37
+ :versions, -> { order('UPPER(_during) ASC') },
38
38
  dependent: nil,
39
39
  class_name: version_class.to_s,
40
40
  inverse_of: model_name.i18n_key
41
41
  )
42
+
43
+ # @!scope class
44
+ # @!method at
45
+ # @return [ActiveRecord<Object>]
46
+ #
47
+ # Returns instances of the source model and versions that were valid at the supplied
48
+ # +datetime+ or +time+, all cast as instances of the source model.
49
+ scope :at, lambda { |datetime|
50
+ versioned = version_class.at(datetime)
51
+ trashed = version_class.trashed_at(datetime)
52
+ foreign_key = version_class.hoardable_source_foreign_key
53
+ include_versions.where(id: versioned.select('id')).or(
54
+ where.not(id: versioned.select(foreign_key)).where.not(id: trashed.select(foreign_key))
55
+ )
56
+ }
42
57
  end
43
58
 
44
59
  # Returns a boolean of whether the record is actually a trashed +version+.
45
60
  #
46
61
  # @return [Boolean]
47
62
  def trashed?
48
- versions.trashed.limit(1).order(_during: :desc).first&.send(:hoardable_source_attributes) == attributes
63
+ versions.trashed.only_most_recent.first&.hoardable_source_foreign_id == id
49
64
  end
50
65
 
51
66
  # Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
@@ -71,27 +86,31 @@ module Hoardable
71
86
  private
72
87
 
73
88
  def hoardable_callbacks_enabled
74
- self.class.hoardable_options[:enabled] && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
89
+ self.class.hoardable_config[:enabled] && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
75
90
  end
76
91
 
77
92
  def hoardable_save_trash
78
- self.class.hoardable_options[:save_trash]
93
+ self.class.hoardable_config[:save_trash]
79
94
  end
80
95
 
81
96
  def hoardable_version_updates
82
- self.class.hoardable_options[:version_updates]
97
+ self.class.hoardable_config[:version_updates]
83
98
  end
84
99
 
85
100
  def insert_hoardable_version_on_update(&block)
86
- insert_hoardable_version('update', attributes_before_type_cast.without('id'), &block)
101
+ insert_hoardable_version('update', &block)
87
102
  end
88
103
 
89
104
  def insert_hoardable_version_on_destroy(&block)
90
- insert_hoardable_version('delete', attributes_before_type_cast, &block)
105
+ insert_hoardable_version('delete', &block)
106
+ end
107
+
108
+ def insert_hoardable_version_on_untrashed
109
+ initialize_hoardable_version('insert').save(validate: false, touch: false)
91
110
  end
92
111
 
93
- def insert_hoardable_version(operation, attrs)
94
- @hoardable_version = initialize_hoardable_version(operation, attrs)
112
+ def insert_hoardable_version(operation)
113
+ @hoardable_version = initialize_hoardable_version(operation)
95
114
  run_callbacks(:versioned) do
96
115
  yield
97
116
  hoardable_version.save(validate: false, touch: false)
@@ -102,9 +121,9 @@ module Hoardable
102
121
  Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
103
122
  end
104
123
 
105
- def initialize_hoardable_version(operation, attrs)
124
+ def initialize_hoardable_version(operation)
106
125
  versions.new(
107
- attrs.merge(
126
+ attributes_before_type_cast.without('id').merge(
108
127
  changes.transform_values { |h| h[0] },
109
128
  {
110
129
  _event_uuid: find_or_initialize_hoardable_event_uuid,
@@ -5,21 +5,28 @@ module Hoardable
5
5
  module Tableoid
6
6
  extend ActiveSupport::Concern
7
7
 
8
- # @!visibility private
9
8
  TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
10
9
  arel_table[:tableoid].send(
11
10
  condition,
12
11
  Arel::Nodes::NamedFunction.new('CAST', [Arel::Nodes::Quoted.new(arel_table.name).as('regclass')])
13
12
  )
14
13
  end.freeze
14
+ private_constant :TABLEOID_AREL_CONDITIONS
15
15
 
16
16
  included do
17
17
  # @!visibility private
18
18
  attr_writer :tableoid
19
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)) }
20
+ # By default {Hoardable} only returns instances of the parent table, and not the +versions+ in
21
+ # the inherited table. This can be bypassed by using the {.include_versions} scope or wrapping
22
+ # the code in a `Hoardable.with(return_everything: true)` block.
23
+ default_scope do
24
+ if hoardable_config[:return_everything]
25
+ where(nil)
26
+ else
27
+ exclude_versions
28
+ end
29
+ end
23
30
 
24
31
  # @!scope class
25
32
  # @!method include_versions
@@ -36,6 +43,14 @@ module Hoardable
36
43
  # Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
37
44
  # model’s class.
38
45
  scope :versions, -> { include_versions.where(TABLEOID_AREL_CONDITIONS.call(arel_table, :not_eq)) }
46
+
47
+ # @!scope class
48
+ # @!method exclude_versions
49
+ # @return [ActiveRecord<Object>]
50
+ #
51
+ # Excludes +versions+ of the parent +ActiveRecord+ class. This is included by default in the
52
+ # source model’s +default_scope+.
53
+ scope :exclude_versions, -> { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
39
54
  end
40
55
 
41
56
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- VERSION = '0.2.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -6,6 +6,13 @@ module Hoardable
6
6
  module VersionModel
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ class_methods do
10
+ # Returns the foreign column that holds the reference to the source model of the version.
11
+ def hoardable_source_foreign_key
12
+ @hoardable_source_foreign_key ||= "#{superclass.model_name.i18n_key}_id"
13
+ end
14
+ end
15
+
9
16
  included do
10
17
  hoardable_source_key = superclass.model_name.i18n_key
11
18
 
@@ -13,7 +20,7 @@ module Hoardable
13
20
  belongs_to hoardable_source_key, inverse_of: :versions
14
21
  alias_method :hoardable_source, hoardable_source_key
15
22
 
16
- self.table_name = "#{table_name.singularize}#{Hoardable::VERSION_TABLE_SUFFIX}"
23
+ self.table_name = "#{table_name.singularize}#{VERSION_TABLE_SUFFIX}"
17
24
 
18
25
  alias_method :readonly?, :persisted?
19
26
  alias_attribute :hoardable_operation, :_operation
@@ -26,7 +33,7 @@ module Hoardable
26
33
  # @!method trashed
27
34
  # @return [ActiveRecord<Object>]
28
35
  #
29
- # Returns only trashed +versions+ that are orphans.
36
+ # Returns only trashed +versions+ that are currently orphans.
30
37
  scope :trashed, lambda {
31
38
  left_outer_joins(hoardable_source_key)
32
39
  .where(superclass.table_name => { id: nil })
@@ -38,7 +45,14 @@ module Hoardable
38
45
  # @return [ActiveRecord<Object>]
39
46
  #
40
47
  # Returns +versions+ that were valid at the supplied +datetime+ or +time+.
41
- scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
48
+ scope :at, ->(datetime) { where(_operation: %w[delete update]).where(DURING_QUERY, datetime) }
49
+
50
+ # @!scope class
51
+ # @!method trashed_at
52
+ # @return [ActiveRecord<Object>]
53
+ #
54
+ # Returns +versions+ that were trashed at the supplied +datetime+ or +time+.
55
+ scope :trashed_at, ->(datetime) { where(_operation: 'insert').where(DURING_QUERY, datetime) }
42
56
 
43
57
  # @!scope class
44
58
  # @!method with_hoardable_event_uuid
@@ -47,6 +61,13 @@ module Hoardable
47
61
  # Returns all +versions+ that were created as part of the same +ActiveRecord+ database
48
62
  # transaction of the supplied +event_uuid+. Useful in +reverted+ and +untrashed+ callbacks.
49
63
  scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
64
+
65
+ # @!scope class
66
+ # @!method only_most_recent
67
+ # @return [ActiveRecord<Object>]
68
+ #
69
+ # Returns a limited +ActiveRecord+ scope of only the most recent version.
70
+ scope :only_most_recent, -> { limit(1).reorder('UPPER(_during) DESC') }
50
71
  end
51
72
 
52
73
  # Reverts the parent +ActiveRecord+ instance to the saved attributes of this +version+. Raises
@@ -70,8 +91,9 @@ module Hoardable
70
91
 
71
92
  transaction do
72
93
  superscope = self.class.superclass.unscoped
73
- superscope.insert(untrashable_hoardable_source_attributes)
94
+ superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_foreign_id))
74
95
  superscope.find(hoardable_source_foreign_id).tap do |untrashed|
96
+ untrashed.send('insert_hoardable_version_on_untrashed')
75
97
  untrashed.instance_variable_set(:@hoardable_version, self)
76
98
  untrashed.run_callbacks(:untrashed)
77
99
  end
@@ -91,13 +113,14 @@ module Hoardable
91
113
  _data&.dig('changes')
92
114
  end
93
115
 
116
+ # Returns the foreign reference that represents the source model of the version.
117
+ def hoardable_source_foreign_id
118
+ @hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
119
+ end
120
+
94
121
  private
95
122
 
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
100
- end
123
+ delegate :hoardable_source_foreign_key, to: :class
101
124
 
102
125
  def hoardable_source_attributes
103
126
  @hoardable_source_attributes ||=
@@ -106,16 +129,8 @@ module Hoardable
106
129
  .reject { |k, _v| k.start_with?('_') }
107
130
  end
108
131
 
109
- def hoardable_source_foreign_key
110
- @hoardable_source_foreign_key ||= "#{self.class.superclass.model_name.i18n_key}_id"
111
- end
112
-
113
- def hoardable_source_foreign_id
114
- @hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
115
- end
116
-
117
132
  def previous_temporal_tsrange_end
118
- hoardable_source.versions.limit(1).order(_during: :desc).pluck('_during').first&.end
133
+ hoardable_source.versions.only_most_recent.pluck('_during').first&.end
119
134
  end
120
135
 
121
136
  def assign_temporal_tsrange
@@ -124,10 +139,10 @@ module Hoardable
124
139
  if hoardable_source.class.column_names.include?('created_at')
125
140
  hoardable_source.created_at
126
141
  else
127
- Time.at(0)
142
+ Time.at(0).utc
128
143
  end
129
144
  )
130
- self._during = (range_start..Time.now)
145
+ self._during = (range_start..Time.now.utc)
131
146
  end
132
147
  end
133
148
  end
data/lib/hoardable.rb CHANGED
@@ -7,4 +7,5 @@ require_relative 'hoardable/error'
7
7
  require_relative 'hoardable/source_model'
8
8
  require_relative 'hoardable/version_model'
9
9
  require_relative 'hoardable/model'
10
+ require_relative 'hoardable/associations'
10
11
  require_relative 'generators/hoardable/migration_generator'
data/sig/hoardable.rbs CHANGED
@@ -1,7 +1,7 @@
1
1
  module Hoardable
2
2
  VERSION: String
3
3
  DATA_KEYS: [:meta, :whodunit, :note, :event_uuid]
4
- CONFIG_KEYS: [:enabled, :version_updates, :save_trash]
4
+ CONFIG_KEYS: [:enabled, :version_updates, :save_trash, :return_everything]
5
5
  VERSION_CLASS_SUFFIX: String
6
6
  VERSION_TABLE_SUFFIX: String
7
7
  DURING_QUERY: String
@@ -71,8 +71,9 @@ module Hoardable
71
71
  include VersionModel
72
72
  include SourceModel
73
73
 
74
- attr_reader _hoardable_options: Hash[untyped, untyped]
75
- def hoardable_options: (?nil hash) -> untyped
74
+ attr_reader _hoardable_config: Hash[untyped, untyped]
75
+ def hoardable_config: (?nil hash) -> untyped
76
+ def with_hoardable_config: (untyped hash) -> untyped
76
77
  end
77
78
 
78
79
  class MigrationGenerator
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.2.0
4
+ version: 0.5.0
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-08-08 00:00:00.000000000 Z
11
+ date: 2022-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -108,6 +108,7 @@ files:
108
108
  - lib/generators/hoardable/templates/migration.rb.erb
109
109
  - lib/generators/hoardable/templates/migration_6.rb.erb
110
110
  - lib/hoardable.rb
111
+ - lib/hoardable/associations.rb
111
112
  - lib/hoardable/error.rb
112
113
  - lib/hoardable/hoardable.rb
113
114
  - lib/hoardable/model.rb