hoardable 0.3.0 → 0.6.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: 50086ef99aac41454b28ab8bff2e8abf94d7870b371da0c706c477e0b296ffc3
4
- data.tar.gz: 6e6d4ab40470bbfb93e23e96fdbb2d8b086878d24e55d499ac368523125e5397
3
+ metadata.gz: dff71dd2ebbebaeedfdc9d6fdd8c56c8faaa5505814cfe41c146c4a565375ff1
4
+ data.tar.gz: 6693f3634541bc8308ed5b4329d69851d971df1cec2b50e842fa9332c62425b6
5
5
  SHA512:
6
- metadata.gz: 747ac52845c950eb655cb7b0af22794dda49ed08e2b0f1aee472595fd2c63b1f886d4f516e686807607efe4946ca588792913ea92ec1c3152a44c23f8cd84487
7
- data.tar.gz: 3460ccd66ebe6eed7cdb129bca6150696b82ad9f8613965e80b08fb458ac6e2b8b644e662036533f73f95f52b97a10bf50f2782f76d3fd6671f23f9c2b58df08
6
+ metadata.gz: ec2a9df9254cf3623f4fffb0516e99ec30a46ec6496ad0d5e555d4a3ce8324fa81d1880036f42aeb4bbbee136957479f283ccab4b8fb3f2847122bd597483889
7
+ data.tar.gz: 25a5040c6dc5b91b0b54020657e436aa775cf2211b56fecf1b277e36c11041b55f5a29861000503d5388a9a3d0280a8ccca99c052a2dc82a45abb8f5c98bb50e
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,10 @@ Layout/LineLength:
8
9
  Metrics/ClassLength:
9
10
  Exclude:
10
11
  - 'test/**/*.rb'
12
+
13
+ Metrics/BlockLength:
14
+ Exclude:
15
+ - 'test/**/*.rb'
16
+
17
+ Style/DocumentDynamicEvalDefinition:
18
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2022-09-28
4
+
5
+ - **Breaking Change** - Previously, a source model would `has_many :versions` with an inverse
6
+ relationship of the i18n interpreted name of the source model. Now it simply `has_many :versions,
7
+ inverse_of :hoardable_source` to not potentially conflict with previously existing relationships.
8
+
9
+ ## [0.5.0] - 2022-09-25
10
+
11
+ - **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
12
+ it's own temporal timespan. This simplifies the ability to query versions temporarily for when
13
+ they were trashed or not. This changes, but corrects, temporal query results using `.at`.
14
+
15
+ - **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
16
+ you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
17
+ executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
18
+ 'insert';`.
19
+
20
+ ## [0.4.0] - 2022-09-24
21
+
22
+ - **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
23
+ source model’s table.
24
+
3
25
  ## [0.1.0] - 2022-07-23
4
26
 
5
27
  - 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,7 @@ 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
149
- # config/initiailzers/hoardable.rb
179
+ # config/initializers/hoardable.rb
150
180
  Hoardable.whodunit = -> { Current.user&.id }
151
181
 
152
182
  # somewhere in your app code
@@ -177,7 +207,7 @@ class ApplicationController < ActionController::Base
177
207
  Hoardable.with(whodunit: current_user.id, meta: { request_uuid: request.uuid }) do
178
208
  yield
179
209
  end
180
- # `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
181
211
  end
182
212
  end
183
213
  ```
@@ -227,7 +257,7 @@ end
227
257
 
228
258
  ### Configuration
229
259
 
230
- There are three configurable options currently:
260
+ The configurable options are:
231
261
 
232
262
  ```ruby
233
263
  Hoardable.enabled # => default true
@@ -278,18 +308,18 @@ If a model-level option exists, it will use that. Otherwise, it will fall back t
278
308
 
279
309
  ### Relationships
280
310
 
281
- As in life, sometimes relationships can be hard. `hoardable` is still working out best practices and
282
- 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.
283
313
 
284
- Sometimes you’ll have a record that belongs to a record that you’ll trash. Now the child record’s
285
- foreign key will point to the non-existent trashed version of the parent. If you would like this
286
- `belongs_to` relationship to always resolve to the parent as if it was not trashed, you can include
287
- the `include_versions` 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`:
288
318
 
289
319
  ```ruby
290
320
  class Comment
291
- include Hoardable::Model
292
- belongs_to :post, -> { include_versions } # `Post` also includes `Hoardable::Model`
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
293
323
  end
294
324
  ```
295
325
 
@@ -317,7 +347,8 @@ end
317
347
 
318
348
  If there are models that might be related to versions that are trashed or otherwise, and/or might
319
349
  trashed themselves, you can bypass the inherited tables query handling altogether by using the
320
- `return_everything` configuration variable in `Hoardable.with`:
350
+ `return_everything` configuration variable in `Hoardable.with`. This will ensure that you always see
351
+ all records, including update and trashed versions.
321
352
 
322
353
  ```ruby
323
354
  post.destroy!
@@ -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,18 +5,34 @@ 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 return_everything].freeze
11
+ CONFIG_KEYS = %i[enabled version_updates save_trash return_everything warn_on_missing_created_at_column].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
21
+
22
+ HOARDABLE_CALLBACKS_ENABLED = proc do |source_model|
23
+ source_model.class.hoardable_config[:enabled] && !source_model.class.name.end_with?(VERSION_CLASS_SUFFIX)
24
+ end.freeze
25
+ private_constant :HOARDABLE_CALLBACKS_ENABLED
26
+
27
+ HOARDABLE_SAVE_TRASH = proc do |source_model|
28
+ source_model.class.hoardable_config[:save_trash]
29
+ end.freeze
30
+ private_constant :HOARDABLE_SAVE_TRASH
31
+
32
+ HOARDABLE_VERSION_UPDATES = proc do |source_model|
33
+ source_model.class.hoardable_config[:version_updates]
34
+ end.freeze
35
+ private_constant :HOARDABLE_VERSION_UPDATES
20
36
 
21
37
  @context = {}
22
38
  @config = CONFIG_KEYS.to_h do |key|
@@ -44,7 +60,8 @@ module Hoardable
44
60
  end
45
61
  end
46
62
 
47
- # This is a general use method for setting {DATA_KEYS} or {CONFIG_KEYS} around a scoped block.
63
+ # This is a general use method for setting {file:README.md#tracking-contextual-data Contextual
64
+ # Data} or {file:README.md#configuration Configuration} around a block.
48
65
  #
49
66
  # @param hash [Hash] config and contextual data to set within a block
50
67
  def with(hash)
@@ -57,5 +74,10 @@ module Hoardable
57
74
  @config = current_config
58
75
  @context = current_context
59
76
  end
77
+
78
+ # @!visibility private
79
+ def logger
80
+ @logger ||= ActiveSupport::TaggedLogging.new(Logger.new($stdout))
81
+ end
60
82
  end
61
83
  end
@@ -21,10 +21,10 @@ module Hoardable
21
21
  # @return [Hash]
22
22
  def hoardable_config(hash = nil)
23
23
  if hash
24
- @_hoardable_config = hash.slice(*Hoardable::CONFIG_KEYS)
24
+ @_hoardable_config = hash.slice(*CONFIG_KEYS)
25
25
  else
26
26
  @_hoardable_config ||= {}
27
- Hoardable::CONFIG_KEYS.to_h do |key|
27
+ CONFIG_KEYS.to_h do |key|
28
28
  [key, @_hoardable_config.key?(key) ? @_hoardable_config[key] : Hoardable.send(key)]
29
29
  end
30
30
  end
@@ -37,7 +37,7 @@ module Hoardable
37
37
  # {CONFIG_KEYS}
38
38
  def with_hoardable_config(hash)
39
39
  current_config = @_hoardable_config
40
- @_hoardable_config = hash.slice(*Hoardable::CONFIG_KEYS)
40
+ @_hoardable_config = hash.slice(*CONFIG_KEYS)
41
41
  yield
42
42
  ensure
43
43
  @_hoardable_config = current_config
@@ -45,6 +45,7 @@ module Hoardable
45
45
  end
46
46
 
47
47
  included do
48
+ include Associations
48
49
  define_model_callbacks :versioned
49
50
  define_model_callbacks :reverted, only: :after
50
51
  define_model_callbacks :untrashed, only: :after
@@ -7,6 +7,15 @@ module Hoardable
7
7
  module SourceModel
8
8
  extend ActiveSupport::Concern
9
9
 
10
+ # The +Version+ class instance for use within +versioned+, +reverted+, and +untrashed+ callbacks.
11
+ attr_reader :hoardable_version
12
+
13
+ # @!attribute [r] hoardable_event_uuid
14
+ # @return [String] A postgres UUID that represents the +version+’s +ActiveRecord+ database transaction
15
+ # @!attribute [r] hoardable_operation
16
+ # @return [String] The database operation that created the +version+ - either +update+ or +delete+.
17
+ delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
18
+
10
19
  class_methods do
11
20
  # The dynamically generated +Version+ class for this model.
12
21
  def version_class
@@ -17,35 +26,46 @@ module Hoardable
17
26
  included do
18
27
  include Tableoid
19
28
 
20
- around_update :insert_hoardable_version_on_update, if: %i[hoardable_callbacks_enabled hoardable_version_updates]
21
- around_destroy :insert_hoardable_version_on_destroy, if: %i[hoardable_callbacks_enabled hoardable_save_trash]
22
- before_destroy :delete_hoardable_versions, if: :hoardable_callbacks_enabled, unless: :hoardable_save_trash
23
- after_commit :unset_hoardable_version_and_event_uuid
29
+ around_update(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_VERSION_UPDATES]) do |_, block|
30
+ hoardable_source_service.insert_hoardable_version('update', &block)
31
+ end
24
32
 
25
- # This will contain the +Version+ class instance for use within +versioned+, +reverted+, and
26
- # +untrashed+ callbacks.
27
- attr_reader :hoardable_version
33
+ around_destroy(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_SAVE_TRASH]) do |_, block|
34
+ hoardable_source_service.insert_hoardable_version('delete', &block)
35
+ end
28
36
 
29
- # @!attribute [r] hoardable_event_uuid
30
- # @return [String] A postgres UUID that represents the +version+’s +ActiveRecord+ database transaction
31
- # @!attribute [r] hoardable_operation
32
- # @return [String] The database operation that created the +version+ - either +update+ or +delete+.
33
- delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
37
+ before_destroy(if: HOARDABLE_CALLBACKS_ENABLED, unless: HOARDABLE_SAVE_TRASH) do
38
+ versions.delete_all(:delete_all)
39
+ end
40
+
41
+ after_commit { hoardable_source_service.unset_hoardable_version_and_event_uuid }
34
42
 
35
43
  # Returns all +versions+ in ascending order of their temporal timeframes.
36
44
  has_many(
37
- :versions, -> { order(:_during) },
45
+ :versions, -> { order('UPPER(_during) ASC') },
38
46
  dependent: nil,
39
47
  class_name: version_class.to_s,
40
- inverse_of: model_name.i18n_key
48
+ inverse_of: :hoardable_source
41
49
  )
50
+
51
+ # @!scope class
52
+ # @!method at
53
+ # @return [ActiveRecord<Object>]
54
+ #
55
+ # Returns instances of the source model and versions that were valid at the supplied
56
+ # +datetime+ or +time+, all cast as instances of the source model.
57
+ scope :at, lambda { |datetime|
58
+ include_versions.where(id: version_class.at(datetime).select('id')).or(
59
+ where.not(id: version_class.select(version_class.hoardable_source_foreign_key).where(DURING_QUERY, datetime))
60
+ )
61
+ }
42
62
  end
43
63
 
44
64
  # Returns a boolean of whether the record is actually a trashed +version+.
45
65
  #
46
66
  # @return [Boolean]
47
67
  def trashed?
48
- versions.trashed.limit(1).order(_during: :desc).first&.id == id
68
+ versions.trashed.only_most_recent.first&.hoardable_source_foreign_id == id
49
69
  end
50
70
 
51
71
  # Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
@@ -55,7 +75,7 @@ module Hoardable
55
75
  def at(datetime)
56
76
  raise(Error, 'Future state cannot be known') if datetime.future?
57
77
 
58
- versions.find_by(DURING_QUERY, datetime) || self
78
+ versions.at(datetime).first || self
59
79
  end
60
80
 
61
81
  # If a version is found at the supplied datetime, it will +revert!+ to it and return it. This
@@ -70,72 +90,63 @@ module Hoardable
70
90
 
71
91
  private
72
92
 
73
- def hoardable_callbacks_enabled
74
- self.class.hoardable_config[:enabled] && !self.class.name.end_with?(VERSION_CLASS_SUFFIX)
75
- end
76
-
77
- def hoardable_save_trash
78
- self.class.hoardable_config[:save_trash]
93
+ def hoardable_source_service
94
+ @hoardable_source_service ||= Service.new(self)
79
95
  end
80
96
 
81
- def hoardable_version_updates
82
- self.class.hoardable_config[:version_updates]
83
- end
97
+ # This is a private service class that manages the insertion of {VersionModel}s for a
98
+ # {SourceModel} into the PostgreSQL database.
99
+ class Service
100
+ attr_reader :source_model
84
101
 
85
- def insert_hoardable_version_on_update(&block)
86
- insert_hoardable_version('update', attributes_before_type_cast.without('id'), &block)
87
- end
88
-
89
- def insert_hoardable_version_on_destroy(&block)
90
- insert_hoardable_version('delete', attributes_before_type_cast, &block)
91
- end
102
+ def initialize(source_model)
103
+ @source_model = source_model
104
+ end
92
105
 
93
- def insert_hoardable_version(operation, attrs)
94
- @hoardable_version = initialize_hoardable_version(operation, attrs)
95
- run_callbacks(:versioned) do
96
- yield
97
- hoardable_version.save(validate: false, touch: false)
106
+ def insert_hoardable_version(operation)
107
+ source_model.instance_variable_set('@hoardable_version', initialize_hoardable_version(operation))
108
+ source_model.run_callbacks(:versioned) do
109
+ yield if block_given?
110
+ source_model.hoardable_version.save(validate: false, touch: false)
111
+ end
98
112
  end
99
- end
100
113
 
101
- def find_or_initialize_hoardable_event_uuid
102
- Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
103
- end
114
+ def find_or_initialize_hoardable_event_uuid
115
+ Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
116
+ end
104
117
 
105
- def initialize_hoardable_version(operation, attrs)
106
- versions.new(
107
- attrs.merge(
108
- changes.transform_values { |h| h[0] },
109
- {
110
- _event_uuid: find_or_initialize_hoardable_event_uuid,
111
- _operation: operation,
112
- _data: initialize_hoardable_data.merge(changes: changes)
113
- }
118
+ def initialize_hoardable_version(operation)
119
+ source_model.versions.new(
120
+ source_model.attributes_before_type_cast.without('id').merge(
121
+ source_model.changes.transform_values { |h| h[0] },
122
+ {
123
+ _event_uuid: find_or_initialize_hoardable_event_uuid,
124
+ _operation: operation,
125
+ _data: initialize_hoardable_data.merge(changes: source_model.changes)
126
+ }
127
+ )
114
128
  )
115
- )
116
- end
117
-
118
- def initialize_hoardable_data
119
- DATA_KEYS.to_h do |key|
120
- [key, assign_hoardable_context(key)]
121
129
  end
122
- end
123
130
 
124
- def assign_hoardable_context(key)
125
- return nil if (value = Hoardable.public_send(key)).nil?
131
+ def initialize_hoardable_data
132
+ DATA_KEYS.to_h do |key|
133
+ [key, assign_hoardable_context(key)]
134
+ end
135
+ end
126
136
 
127
- value.is_a?(Proc) ? value.call : value
128
- end
137
+ def assign_hoardable_context(key)
138
+ return nil if (value = Hoardable.public_send(key)).nil?
129
139
 
130
- def delete_hoardable_versions
131
- versions.delete_all(:delete_all)
132
- end
140
+ value.is_a?(Proc) ? value.call : value
141
+ end
133
142
 
134
- def unset_hoardable_version_and_event_uuid
135
- @hoardable_version = nil
136
- return if ActiveRecord::Base.connection.transaction_open?
143
+ def unset_hoardable_version_and_event_uuid
144
+ source_model.instance_variable_set('@hoardable_version', nil)
145
+ return if source_model.class.connection.transaction_open?
137
146
 
138
- Thread.current[:hoardable_event_uuid] = nil
147
+ Thread.current[:hoardable_event_uuid] = nil
148
+ end
139
149
  end
150
+ private_constant :Service
140
151
  end
141
152
  end
@@ -5,13 +5,13 @@ 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
@@ -24,7 +24,7 @@ module Hoardable
24
24
  if hoardable_config[:return_everything]
25
25
  where(nil)
26
26
  else
27
- where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq))
27
+ exclude_versions
28
28
  end
29
29
  end
30
30
 
@@ -43,6 +43,14 @@ module Hoardable
43
43
  # Returns only +versions+ of the parent +ActiveRecord+ class, cast as instances of the source
44
44
  # model’s class.
45
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)) }
46
54
  end
47
55
 
48
56
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- VERSION = '0.3.0'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -6,29 +6,38 @@ module Hoardable
6
6
  module VersionModel
7
7
  extend ActiveSupport::Concern
8
8
 
9
- included do
10
- hoardable_source_key = superclass.model_name.i18n_key
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
11
15
 
16
+ included do
12
17
  # A +version+ belongs to it’s parent +ActiveRecord+ source.
13
- belongs_to hoardable_source_key, inverse_of: :versions
14
- alias_method :hoardable_source, hoardable_source_key
18
+ belongs_to(
19
+ :hoardable_source,
20
+ inverse_of: :versions,
21
+ class_name: superclass.model_name,
22
+ foreign_key: hoardable_source_foreign_key
23
+ )
15
24
 
16
- self.table_name = "#{table_name.singularize}#{Hoardable::VERSION_TABLE_SUFFIX}"
25
+ self.table_name = "#{table_name.singularize}#{VERSION_TABLE_SUFFIX}"
17
26
 
18
27
  alias_method :readonly?, :persisted?
19
28
  alias_attribute :hoardable_operation, :_operation
20
29
  alias_attribute :hoardable_event_uuid, :_event_uuid
21
30
  alias_attribute :hoardable_during, :_during
22
31
 
23
- before_create :assign_temporal_tsrange
32
+ before_create { hoardable_version_service.assign_temporal_tsrange }
24
33
 
25
34
  # @!scope class
26
35
  # @!method trashed
27
36
  # @return [ActiveRecord<Object>]
28
37
  #
29
- # Returns only trashed +versions+ that are orphans.
38
+ # Returns only trashed +versions+ that are currently orphans.
30
39
  scope :trashed, lambda {
31
- left_outer_joins(hoardable_source_key)
40
+ left_outer_joins(:hoardable_source)
32
41
  .where(superclass.table_name => { id: nil })
33
42
  .where(_operation: 'delete')
34
43
  }
@@ -38,7 +47,14 @@ module Hoardable
38
47
  # @return [ActiveRecord<Object>]
39
48
  #
40
49
  # Returns +versions+ that were valid at the supplied +datetime+ or +time+.
41
- scope :at, ->(datetime) { where(DURING_QUERY, datetime) }
50
+ scope :at, ->(datetime) { where(_operation: %w[delete update]).where(DURING_QUERY, datetime) }
51
+
52
+ # @!scope class
53
+ # @!method trashed_at
54
+ # @return [ActiveRecord<Object>]
55
+ #
56
+ # Returns +versions+ that were trashed at the supplied +datetime+ or +time+.
57
+ scope :trashed_at, ->(datetime) { where(_operation: 'insert').where(DURING_QUERY, datetime) }
42
58
 
43
59
  # @!scope class
44
60
  # @!method with_hoardable_event_uuid
@@ -47,6 +63,13 @@ module Hoardable
47
63
  # Returns all +versions+ that were created as part of the same +ActiveRecord+ database
48
64
  # transaction of the supplied +event_uuid+. Useful in +reverted+ and +untrashed+ callbacks.
49
65
  scope :with_hoardable_event_uuid, ->(event_uuid) { where(_event_uuid: event_uuid) }
66
+
67
+ # @!scope class
68
+ # @!method only_most_recent
69
+ # @return [ActiveRecord<Object>]
70
+ #
71
+ # Returns a limited +ActiveRecord+ scope of only the most recent version.
72
+ scope :only_most_recent, -> { limit(1).reorder('UPPER(_during) DESC') }
50
73
  end
51
74
 
52
75
  # Reverts the parent +ActiveRecord+ instance to the saved attributes of this +version+. Raises
@@ -56,7 +79,7 @@ module Hoardable
56
79
 
57
80
  transaction do
58
81
  hoardable_source.tap do |reverted|
59
- reverted.update!(hoardable_source_attributes.without('id'))
82
+ reverted.update!(hoardable_version_service.hoardable_source_attributes.without('id'))
60
83
  reverted.instance_variable_set(:@hoardable_version, self)
61
84
  reverted.run_callbacks(:reverted)
62
85
  end
@@ -69,9 +92,8 @@ module Hoardable
69
92
  raise(Error, 'Version is not trashed, cannot untrash') unless hoardable_operation == 'delete'
70
93
 
71
94
  transaction do
72
- superscope = self.class.superclass.unscoped
73
- superscope.insert(untrashable_hoardable_source_attributes)
74
- superscope.find(hoardable_source_foreign_id).tap do |untrashed|
95
+ hoardable_version_service.insert_untrashed_source.tap do |untrashed|
96
+ untrashed.send('hoardable_source_service').insert_hoardable_version('insert')
75
97
  untrashed.instance_variable_set(:@hoardable_version, self)
76
98
  untrashed.run_callbacks(:untrashed)
77
99
  end
@@ -91,43 +113,72 @@ module Hoardable
91
113
  _data&.dig('changes')
92
114
  end
93
115
 
94
- private
95
-
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
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)
100
119
  end
101
120
 
102
- def hoardable_source_attributes
103
- @hoardable_source_attributes ||=
104
- attributes_before_type_cast
105
- .without(hoardable_source_foreign_key)
106
- .reject { |k, _v| k.start_with?('_') }
107
- end
121
+ delegate :hoardable_source_foreign_key, to: :class
108
122
 
109
- def hoardable_source_foreign_key
110
- @hoardable_source_foreign_key ||= "#{self.class.superclass.model_name.i18n_key}_id"
123
+ def hoardable_version_service
124
+ @hoardable_version_service ||= Service.new(self)
111
125
  end
112
126
 
113
- def hoardable_source_foreign_id
114
- @hoardable_source_foreign_id ||= public_send(hoardable_source_foreign_key)
115
- end
127
+ # This is a private service class that manages the construction of {VersionModel} attributes and
128
+ # untrashing / re-insertion into the {SourceModel} table.
129
+ class Service
130
+ attr_reader :version_model
116
131
 
117
- def previous_temporal_tsrange_end
118
- hoardable_source.versions.limit(1).order(_during: :desc).pluck('_during').first&.end
119
- end
132
+ def initialize(version_model)
133
+ @version_model = version_model
134
+ end
135
+
136
+ delegate :hoardable_source_foreign_id, :hoardable_source_foreign_key, :hoardable_source, to: :version_model
137
+
138
+ def insert_untrashed_source
139
+ superscope = version_model.class.superclass.unscoped
140
+ superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_foreign_id))
141
+ superscope.find(hoardable_source_foreign_id)
142
+ end
143
+
144
+ def hoardable_source_attributes
145
+ @hoardable_source_attributes ||=
146
+ version_model
147
+ .attributes_before_type_cast
148
+ .without(hoardable_source_foreign_key)
149
+ .reject { |k, _v| k.start_with?('_') }
150
+ end
151
+
152
+ def previous_temporal_tsrange_end
153
+ hoardable_source.versions.only_most_recent.pluck('_during').first&.end
154
+ end
120
155
 
121
- def assign_temporal_tsrange
122
- range_start = (
123
- previous_temporal_tsrange_end ||
156
+ def hoardable_source_epoch
124
157
  if hoardable_source.class.column_names.include?('created_at')
125
158
  hoardable_source.created_at
126
159
  else
127
- Time.at(0)
160
+ maybe_warn_about_missing_created_at_column
161
+ Time.at(0).utc
128
162
  end
129
- )
130
- self._during = (range_start..Time.now)
163
+ end
164
+
165
+ def assign_temporal_tsrange
166
+ version_model._during = ((previous_temporal_tsrange_end || hoardable_source_epoch)..Time.now.utc)
167
+ end
168
+
169
+ def maybe_warn_about_missing_created_at_column
170
+ return unless hoardable_source.class.hoardable_config[:warn_on_missing_created_at_column]
171
+
172
+ source_table_name = hoardable_source.class.table_name
173
+ Hoardable.logger.info(
174
+ <<~LOG
175
+ '#{source_table_name}' does not have a 'created_at' column, so the first version’s temporal period
176
+ will begin at the unix epoch instead. Add a 'created_at' column to '#{source_table_name}'
177
+ or set 'Hoardable.warn_on_missing_created_at_column = false' to disable this message.
178
+ LOG
179
+ )
180
+ end
131
181
  end
182
+ private_constant :Service
132
183
  end
133
184
  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,14 +1,19 @@
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, :return_everything]
4
+ CONFIG_KEYS: [:enabled, :version_updates, :save_trash, :return_everything, :warn_on_missing_created_at_column]
5
5
  VERSION_CLASS_SUFFIX: String
6
6
  VERSION_TABLE_SUFFIX: String
7
7
  DURING_QUERY: String
8
+ HOARDABLE_CALLBACKS_ENABLED: ^(untyped) -> untyped
9
+ HOARDABLE_SAVE_TRASH: ^(untyped) -> untyped
10
+ HOARDABLE_VERSION_UPDATES: ^(untyped) -> untyped
8
11
  self.@context: Hash[untyped, untyped]
9
12
  self.@config: untyped
13
+ self.@logger: untyped
10
14
 
11
15
  def self.with: (untyped hash) -> untyped
16
+ def self.logger: -> untyped
12
17
 
13
18
  module Tableoid
14
19
  TABLEOID_AREL_CONDITIONS: Proc
@@ -25,56 +30,71 @@ module Hoardable
25
30
 
26
31
  module SourceModel
27
32
  include Tableoid
33
+ @hoardable_source_service: Service
28
34
 
35
+ attr_reader hoardable_version: nil
29
36
  def trashed?: -> untyped
30
37
  def at: (untyped datetime) -> SourceModel
31
38
  def revert_to!: (untyped datetime) -> SourceModel?
32
39
 
33
40
  private
34
- def hoardable_callbacks_enabled: -> untyped
35
- def hoardable_save_trash: -> untyped
36
- def hoardable_version_updates: -> untyped
37
- def insert_hoardable_version_on_update: -> untyped
38
- def insert_hoardable_version_on_destroy: -> untyped
39
- def insert_hoardable_version: (String operation, untyped attrs) -> untyped
40
- def find_or_initialize_hoardable_event_uuid: -> untyped
41
- def initialize_hoardable_version: (String operation, untyped attrs) -> untyped
42
- def initialize_hoardable_data: -> untyped
43
- def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
44
- def delete_hoardable_versions: -> untyped
45
- def unset_hoardable_version_and_event_uuid: -> nil
41
+ def hoardable_source_service: -> Service
46
42
 
47
43
  public
48
44
  def version_class: -> untyped
49
- attr_reader hoardable_version: nil
45
+
46
+ class Service
47
+ attr_reader source_model: SourceModel
48
+ def initialize: (SourceModel source_model) -> void
49
+ def insert_hoardable_version: (untyped operation) -> untyped
50
+ def find_or_initialize_hoardable_event_uuid: -> untyped
51
+ def initialize_hoardable_version: (untyped operation) -> untyped
52
+ def initialize_hoardable_data: -> untyped
53
+ def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
54
+ def unset_hoardable_version_and_event_uuid: -> nil
55
+ end
50
56
  end
51
57
 
52
58
  module VersionModel
53
- @hoardable_source_attributes: untyped
54
- @hoardable_source_foreign_key: String
55
59
  @hoardable_source_foreign_id: untyped
60
+ @hoardable_source_foreign_key: String
61
+ @hoardable_version_service: Service
56
62
 
57
63
  def revert!: -> untyped
58
64
  def untrash!: -> untyped
59
65
  def changes: -> untyped
60
-
61
- private
62
- def untrashable_hoardable_source_attributes: -> untyped
63
- def hoardable_source_attributes: -> untyped
64
- def hoardable_source_foreign_key: -> String
65
66
  def hoardable_source_foreign_id: -> untyped
66
- def previous_temporal_tsrange_end: -> untyped
67
- def assign_temporal_tsrange: -> Range
67
+ def hoardable_version_service: -> Service
68
+ def hoardable_source_foreign_key: -> String
69
+
70
+ class Service
71
+ @hoardable_source_attributes: untyped
72
+
73
+ attr_reader version_model: VersionModel
74
+ def initialize: (VersionModel version_model) -> void
75
+ def insert_untrashed_source: -> untyped
76
+ def hoardable_source_attributes: -> untyped
77
+ def previous_temporal_tsrange_end: -> untyped
78
+ def hoardable_source_epoch: -> Time
79
+ def assign_temporal_tsrange: -> Range
80
+ def maybe_warn_about_missing_created_at_column: -> nil
81
+ end
68
82
  end
69
83
 
70
84
  module Model
71
85
  include VersionModel
72
86
  include SourceModel
87
+ include Associations
73
88
 
89
+ attr_reader _hoardable_config: Hash[untyped, untyped]
74
90
  def hoardable_config: (?nil hash) -> untyped
75
91
  def with_hoardable_config: (untyped hash) -> untyped
76
92
  end
77
93
 
94
+ module Associations
95
+ def belongs_to_trashable: (untyped name, ?nil scope, **untyped) -> untyped
96
+ end
97
+
78
98
  class MigrationGenerator
79
99
  @singularized_table_name: untyped
80
100
 
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.3.0
4
+ version: 0.6.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-23 00:00:00.000000000 Z
11
+ date: 2022-09-28 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