hoardable 0.3.0 → 0.6.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: 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