hoardable 0.2.0 → 0.5.0

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: 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