hoardable 0.8.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cd77fbd3d35df59423ba95f7286703ae0d3b0f78bd9097b3574c713c38aa4c7
4
- data.tar.gz: d5971e9daf59a9fa5343c13e389b20fc5b6ff193b398d05c958f34dfd056d0cb
3
+ metadata.gz: dad9a103d70ce10905528d1d7e56e67a4a8fb89e675b3097165ccede6262a015
4
+ data.tar.gz: a2dc95873a08175254cca315caa3de9b087674df1e4dadd17f42fb26a9080a9d
5
5
  SHA512:
6
- metadata.gz: 5de03adf485f38e3ee3a24999bf770d92792404894529a5317f9788d15b784c0bcd9aa28b3d2966340a1244296cdc7b4ebe6fdccd6791172b44ca498c4bf5fca
7
- data.tar.gz: a39bd22b66c727ec791e704ec3b24592c068dd2eb29ae0fa7d70a440073bd6d635fd7f24f12d3ba34bf05976b311bab9ec6a5a6fdf9f9069311347d89ae8f8c0
6
+ metadata.gz: 02b542ba1fe562750eb16bf6f310b08e2340610e01e7db68eaedb9185e4da4a1d7e7a5a42885fb09376759bf936b8b527de4bedec8399b71119541162e57604f
7
+ data.tar.gz: 77edd48d420fea18333996280d6e31e0daba198b96e67fdc49b4fc7fe1884cb86cc88416e1f486691e2292465818fb50e3c4fc4367d83cc6d751d0adcb39acf6
data/.rubocop.yml CHANGED
@@ -16,3 +16,6 @@ Metrics/BlockLength:
16
16
 
17
17
  Style/DocumentDynamicEvalDefinition:
18
18
  Enabled: false
19
+
20
+ Naming/PredicateName:
21
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -2,52 +2,3 @@
2
2
 
3
3
  - Stability is coming.
4
4
 
5
- ## [0.8.0] - 2022-10-01
6
-
7
- - **Breaking Change** - Due to the performance benefit of using `insert` for database injection of
8
- versions, and a personal opinion that only an `after_versioned` hook might be needed, the
9
- `before_versioned` and `around_versioned` ActiveRecord hooks are removed.
10
-
11
- - **Breaking Change** - Another side effect of the performance benefit gained by using `insert` is
12
- that a source model will need to be reloaded before a call to `versions` on it can access the
13
- latest version after an `update` on the source record.
14
-
15
- - **Breaking Change** - Previously the inherited `_versions` tables did not have a unique index on
16
- the ID column, though it still pulled from the same sequence as the parent table. Prior to version
17
- 0.4.0 though, it was possible to have multiple trashed versions with the same ID. Adding unique
18
- indexes to version tables prior to version 0.4.0 could result in issues.
19
-
20
- ## [0.7.0] - 2022-09-29
21
-
22
- - **Breaking Change** - Continuing along with the change below, the `foreign_key` on the `_versions`
23
- tables is now changed to `hoardable_source_id` instead of the i18n model name dervied foreign key.
24
- The intent is to never leave room for conflict of foreign keys for existing relationships. This
25
- can be resolved by renaming the foreign key columns from their i18n model name derived column
26
- names to `hoardable_source_id`, i.e. `rename_column :post_versions, :post_id, :hoardable_source_id`.
27
-
28
- ## [0.6.0] - 2022-09-28
29
-
30
- - **Breaking Change** - Previously, a source model would `has_many :versions` with an inverse
31
- relationship based on the i18n interpreted name of the source model. Now it simply `has_many
32
- :versions, inverse_of :hoardable_source` to not potentially conflict with previously existing
33
- relationships.
34
-
35
- ## [0.5.0] - 2022-09-25
36
-
37
- - **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
38
- it's own temporal timespan. This simplifies the ability to query versions temporarily for when
39
- they were trashed or not. This changes, but corrects, temporal query results using `.at`.
40
-
41
- - **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
42
- you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
43
- executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
44
- 'insert';`.
45
-
46
- ## [0.4.0] - 2022-09-24
47
-
48
- - **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
49
- source model’s table.
50
-
51
- ## [0.1.0] - 2022-07-23
52
-
53
- - Initial release
data/README.md CHANGED
@@ -31,6 +31,12 @@ gem 'hoardable'
31
31
 
32
32
  And then execute `bundle install`.
33
33
 
34
+ If you would like to generate an initializer with the global [configuration](#configuration) options:
35
+
36
+ ```
37
+ rails g hoardable:initializer
38
+ ```
39
+
34
40
  ### Model Installation
35
41
 
36
42
  You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
@@ -64,6 +70,9 @@ _Note:_ If you are on Rails 6.1, you might want to set `config.active_record.sch
64
70
  in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
65
71
  Rails 7.
66
72
 
73
+ _Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
74
+ need to query versions often, you should add appropriate indexes to the `_versions` tables.
75
+
67
76
  ## Usage
68
77
 
69
78
  ### Overview
@@ -99,7 +108,7 @@ Each `PostVersion` has access to the same attributes, relationships, and other m
99
108
 
100
109
  If you ever need to revert to a specific version, you can call `version.revert!` on it.
101
110
 
102
- ``` ruby
111
+ ```ruby
103
112
  post = Post.create!(title: "Title")
104
113
  post.update!(title: "Whoops")
105
114
  post.reload.versions.last.revert!
@@ -107,7 +116,7 @@ post.title # => "Title"
107
116
  ```
108
117
 
109
118
  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.
119
+ re-insert the model in the parent class’s table with the original primary key.
111
120
 
112
121
  ```ruby
113
122
  post = Post.create!(title: "Title")
@@ -139,16 +148,15 @@ PostVersion.at(1.day.ago).find_by(hoardable_source_id: post.id) # => #<PostVersi
139
148
 
140
149
  The source model class also has an `.at` method:
141
150
 
142
- ``` ruby
151
+ ```ruby
143
152
  Post.at(1.day.ago) # => [#<Post>, #<Post>]
144
153
  ```
145
154
 
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`.
155
+ This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
156
+ valid at that time, all cast as instances of `Post`.
148
157
 
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.
158
+ There is also an `at` method on `Hoardable` itself for more complex temporal resource querying. See
159
+ [Relationships](#relationships) for more.
152
160
 
153
161
  By default, `hoardable` will keep copies of records you have destroyed. You can query them
154
162
  specifically with:
@@ -158,8 +166,9 @@ PostVersion.trashed
158
166
  Post.version_class.trashed # <- same thing as above
159
167
  ```
160
168
 
161
- _Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
162
- need to query versions often, you should add appropriate indexes to the `_versions` tables.
169
+ _Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
170
+ beginning of the first temporal period, you will need to ensure the source model table has a
171
+ `created_at` timestamp column. If this is missing, an error will be raised.
163
172
 
164
173
  ### Tracking Contextual Data
165
174
 
@@ -263,7 +272,6 @@ The configurable options are:
263
272
  Hoardable.enabled # => default true
264
273
  Hoardable.version_updates # => default true
265
274
  Hoardable.save_trash # => default true
266
- Hoardable.return_everything # => default false
267
275
  ```
268
276
 
269
277
  `Hoardable.enabled` controls whether versions will be ever be created.
@@ -273,10 +281,6 @@ Hoardable.return_everything # => default false
273
281
  `Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
274
282
  `false`, all versions of a record will be deleted when the record is destroyed.
275
283
 
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
-
280
284
  If you would like to temporarily set a config setting, you can use `Hoardable.with`:
281
285
 
282
286
  ```ruby
@@ -313,18 +317,62 @@ with `Hoardable` considerations.
313
317
 
314
318
  Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
315
319
  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`:
320
+ to have `belongs_to` resolve to the trashed parent model in this case, you can give it the option of
321
+ `trashable: true`:
318
322
 
319
323
  ```ruby
320
324
  class Comment
321
325
  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
326
+ belongs_to :post, trashable: true
323
327
  end
324
328
  ```
325
329
 
326
- Sometimes youll trash something that `has_many :children, dependent: :destroy` and both the parent
327
- and child model classes include `Hoardable::Model`. Whenever a hoardable version is created in a
330
+ Sometimes you'll have a Hoardable record that `has_many` other Hoardable records and you will want
331
+ to know the state of both the parent record and the children at a cetain point in time. You
332
+ accomplish this by adding `hoardable: true` to the `has_many` relationship and using the
333
+ `Hoardable.at` method:
334
+
335
+ ```ruby
336
+ class Post
337
+ include Hoardable::Model
338
+ has_many :comments, hoardable: true
339
+ end
340
+
341
+ def Comment
342
+ include Hoardable::Model
343
+ end
344
+
345
+ post = Post.create!(title: 'Title')
346
+ comment1 = post.comments.create!(body: 'Comment')
347
+ comment2 = post.comments.create!(body: 'Comment')
348
+ datetime = DateTime.current
349
+ comment2.destroy!
350
+ post.update!(title: 'New Title')
351
+ post_id = post.id # 1
352
+
353
+ Hoardable.at(datetime) do
354
+ post = Post.find(post_id)
355
+ post.title # => 'Title'
356
+ post.comments.size # => 2
357
+ post.id # => 2
358
+ post.version? # => true
359
+ post.hoardable_source_id # => 1
360
+ end
361
+ ```
362
+
363
+ There are some additional details to point out above. Firstly, it is important to note that the
364
+ final `post.id` yields a different value than the originally created `Post`. This is because the
365
+ `post` within the `#at` block is actually a temporal version, since it has been subsequently
366
+ updated, but it has been reified as a `Post` for the purposes of your business logic (serialization,
367
+ rendering views, exporting, etc). Don’t fret - you will not be able to commit any updates to the
368
+ version, even though it is masquerading as a `Post`.
369
+
370
+ If you are ever unsure if a Hoardable record is a "source" or a "version", you can be sure by
371
+ calling `version?` on it. If you want to get the true original source record ID, you can call
372
+ `hoardable_source_id`.
373
+
374
+ Sometimes you’ll trash something that `has_many_hoardable :children, dependent: :destroy` and want
375
+ to untrash everything in a similar dependent manner. Whenever a hoardable version is created in a
328
376
  database transaction, it will create or re-use a unique event UUID for that transaction and tag all
329
377
  versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
330
378
  records that were trashed with it:
@@ -332,7 +380,7 @@ records that were trashed with it:
332
380
  ```ruby
333
381
  class Post < ActiveRecord::Base
334
382
  include Hoardable::Model
335
- has_many :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
383
+ has_many_hoardable :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
336
384
 
337
385
  after_untrashed do
338
386
  Comment
@@ -344,22 +392,6 @@ class Post < ActiveRecord::Base
344
392
  end
345
393
  ```
346
394
 
347
- If there are models that might be related to versions that are trashed or otherwise, and/or might be
348
- trashed themselves, you can bypass the inherited tables query handling altogether by using the
349
- `return_everything` configuration variable in `Hoardable.with`. This will ensure that you always see
350
- all records, including update and trashed versions.
351
-
352
- ```ruby
353
- post.destroy!
354
-
355
- Hoardable.with(return_everything: true) do
356
- post = Post.find(post.id) # returns the trashed post as if it was not
357
- post.comments # returns the trashed comments as well
358
- end
359
-
360
- post.reload # raises ActiveRecord::RecordNotFound
361
- ```
362
-
363
395
  ## Gem Comparison
364
396
 
365
397
  ### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Hoardable
6
+ # Generates an initializer file for {Hoardable} configuration.
7
+ class InitializerGenerator < Rails::Generators::Base
8
+ def create_initializer_file
9
+ create_file(
10
+ 'config/initializers/hoardable.rb',
11
+ <<~TEXT
12
+ # Hoardable configuration defaults are below. Learn more at https://github.com/waymondo/hoardable#configuration
13
+ #
14
+ # Hoardable.enabled = true
15
+ # Hoardable.version_updates = true
16
+ # Hoardable.save_trash = true
17
+ TEXT
18
+ )
19
+ end
20
+ end
21
+ end
@@ -10,6 +10,20 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
10
10
  t.enum :_operation, enum_type: 'hoardable_operation', null: false, index: true
11
11
  t.<%= foreign_key_type %> :hoardable_source_id, null: false, index: true
12
12
  end
13
+ execute(
14
+ <<~SQL
15
+ CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
16
+ LANGUAGE plpgsql AS
17
+ $$BEGIN
18
+ RAISE EXCEPTION 'updating a version is not allowed';
19
+ RETURN NEW;
20
+ END;$$;
21
+
22
+ CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
23
+ BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
24
+ EXECUTE PROCEDURE hoardable_version_prevent_update();
25
+ SQL
26
+ )
13
27
  add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
14
28
  add_index(
15
29
  :<%= singularized_table_name %>_versions,
@@ -25,6 +25,20 @@ class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%=
25
25
  t.column :_operation, :hoardable_operation, null: false, index: true
26
26
  t.<%= foreign_key_type %> :hoardable_source_id, null: false, index: true
27
27
  end
28
+ execute(
29
+ <<~SQL
30
+ CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
31
+ LANGUAGE plpgsql AS
32
+ $$BEGIN
33
+ RAISE EXCEPTION 'updating a version is not allowed';
34
+ RETURN NEW;
35
+ END;$$;
36
+
37
+ CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
38
+ BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
39
+ EXECUTE PROCEDURE hoardable_version_prevent_update();
40
+ SQL
41
+ )
28
42
  add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
29
43
  add_index(
30
44
  :<%= singularized_table_name %>_versions,
@@ -7,28 +7,104 @@ module Hoardable
7
7
  module Associations
8
8
  extend ActiveSupport::Concern
9
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)
10
+ # An +ActiveRecord+ extension that allows looking up {VersionModel}s by +hoardable_source_id+ as
11
+ # if they were {SourceModel}s when using {Hoardable#at}.
12
+ module HasManyExtension
13
+ def scope
14
+ @scope ||= hoardable_scope
15
+ end
16
+
17
+ private
15
18
 
16
- trashable_relationship_name = "trashable_#{name}"
19
+ def hoardable_scope
20
+ if Hoardable.instance_variable_get('@at') &&
21
+ (hoardable_source_id = @association.owner.hoardable_source_id)
22
+ @association.scope.rewhere(@association.reflection.foreign_key => hoardable_source_id)
23
+ else
24
+ @association.scope
25
+ end
26
+ end
27
+ end
28
+ private_constant :HasManyExtension
29
+
30
+ # A private service class for installing +ActiveRecord+ association overrides.
31
+ class Overrider
32
+ attr_reader :klass
33
+
34
+ def initialize(klass)
35
+ @klass = klass
36
+ end
17
37
 
18
- define_method(trashable_relationship_name) do
19
- source_reflection = self.class.reflections[name.to_s]
20
- version_class = source_reflection.version_class
21
- version_class.trashed.only_most_recent.find_by(
38
+ def override_belongs_to(name)
39
+ klass.define_method("trashable_#{name}") do
40
+ source_reflection = klass.reflections[name.to_s]
41
+ source_reflection.version_class.trashed.only_most_recent.find_by(
22
42
  hoardable_source_id: source_reflection.foreign_key
23
43
  )
24
44
  end
25
45
 
26
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
46
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
47
+ def #{name}
48
+ super || trashable_#{name}
49
+ end
50
+ RUBY
51
+ end
52
+
53
+ def override_has_one(name)
54
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
55
+ def #{name}
56
+ return super unless (at = Hoardable.instance_variable_get('@at'))
57
+
58
+ super&.version_at(at) ||
59
+ _reflections["profile"].klass.where(_reflections["profile"].foreign_key => id).first
60
+ end
61
+ RUBY
62
+ end
63
+
64
+ def override_has_many(name)
65
+ # This hack is needed to force Rails to not use any existing method cache so that the
66
+ # {HasManyExtension} scope is always used.
67
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
27
68
  def #{name}
28
- super || #{trashable_relationship_name}
69
+ super.extending
29
70
  end
30
71
  RUBY
31
72
  end
32
73
  end
74
+ private_constant :Overrider
75
+
76
+ class_methods do
77
+ def belongs_to(*args)
78
+ options = args.extract_options!
79
+ trashable = options.delete(:trashable)
80
+ super(*args, **options)
81
+ return unless trashable
82
+
83
+ hoardable_association_overrider.override_belongs_to(args.first)
84
+ end
85
+
86
+ def has_one(*args)
87
+ options = args.extract_options!
88
+ hoardable = options.delete(:hoardable)
89
+ super(*args, **options)
90
+ return unless hoardable
91
+
92
+ hoardable_association_overrider.override_has_one(args.first)
93
+ end
94
+
95
+ def has_many(*args, &block)
96
+ options = args.extract_options!
97
+ options[:extend] = Array(options[:extend]).push(HasManyExtension) if options.delete(:hoardable)
98
+ super(*args, **options, &block)
99
+
100
+ hoardable_association_overrider.override_has_many(args.first)
101
+ end
102
+
103
+ private
104
+
105
+ def hoardable_association_overrider
106
+ @hoardable_association_overrider ||= Overrider.new(self)
107
+ end
108
+ end
33
109
  end
34
110
  end
@@ -23,6 +23,15 @@ module Hoardable
23
23
  Thread.current[:hoardable_event_uuid] ||= ActiveRecord::Base.connection.query('SELECT gen_random_uuid();')[0][0]
24
24
  end
25
25
 
26
+ def hoardable_version_source_id
27
+ @hoardable_version_source_id ||= query_hoardable_version_source_id
28
+ end
29
+
30
+ def query_hoardable_version_source_id
31
+ primary_key = source_record.class.primary_key
32
+ version_class.where(primary_key => source_record.read_attribute(primary_key)).pluck('hoardable_source_id')[0]
33
+ end
34
+
26
35
  def initialize_version_attributes(operation)
27
36
  source_record.attributes_before_type_cast.without('id').merge(
28
37
  source_record.changes.transform_values { |h| h[0] },
@@ -64,25 +73,9 @@ module Hoardable
64
73
  end
65
74
 
66
75
  def hoardable_source_epoch
67
- if source_record.class.column_names.include?('created_at')
68
- source_record.created_at
69
- else
70
- maybe_warn_about_missing_created_at_column
71
- Time.at(0).utc
72
- end
73
- end
76
+ return source_record.created_at if source_record.class.column_names.include?('created_at')
74
77
 
75
- def maybe_warn_about_missing_created_at_column
76
- return unless source_record.class.hoardable_config[:warn_on_missing_created_at_column]
77
-
78
- source_table_name = source_record.class.table_name
79
- Hoardable.logger.info(
80
- <<~LOG
81
- '#{source_table_name}' does not have a 'created_at' column, so the first version’s temporal period
82
- will begin at the unix epoch instead. Add a 'created_at' column to '#{source_table_name}'
83
- or set 'Hoardable.warn_on_missing_created_at_column = false' to disable this message.
84
- LOG
85
- )
78
+ raise CreatedAtColumnMissingError, source_record.class.table_name
86
79
  end
87
80
  end
88
81
  private_constant :DatabaseClient
@@ -3,4 +3,16 @@
3
3
  module Hoardable
4
4
  # A subclass of +StandardError+ for general use within {Hoardable}.
5
5
  class Error < StandardError; end
6
+
7
+ # An error to be raised when 'created_at' columns are missing for {Hoardable::Model}s.
8
+ class CreatedAtColumnMissingError < Error
9
+ def initialize(source_table_name)
10
+ super(
11
+ <<~LOG
12
+ '#{source_table_name}' does not have a 'created_at' column, so the start of the first
13
+ version’s temporal period cannot be known. Add a 'created_at' column to '#{source_table_name}'.
14
+ LOG
15
+ )
16
+ end
17
+ end
6
18
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # A module for overriding +ActiveRecord#find_one+ and +ActiveRecord#find_some+ in the case you are
5
+ # doing a temporal query and the current {SourceModel} record may in fact be a {VersionModel}
6
+ # record. This is extended into the current scope with {Hoardable#at} but can also be opt-ed into
7
+ # with the class method +hoardable+.
8
+ module FinderMethods
9
+ def find_one(id)
10
+ super(hoardable_source_ids([id])[0])
11
+ end
12
+
13
+ def find_some(ids)
14
+ super(hoardable_source_ids(ids))
15
+ end
16
+
17
+ private
18
+
19
+ def hoardable_source_ids(ids)
20
+ ids.map do |id|
21
+ version_class.where(hoardable_source_id: id).select(primary_key).ids[0] || id
22
+ end
23
+ end
24
+ end
25
+ end
@@ -8,7 +8,7 @@ module Hoardable
8
8
 
9
9
  # Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
10
10
  # README} for more.
11
- CONFIG_KEYS = %i[enabled version_updates save_trash return_everything warn_on_missing_created_at_column].freeze
11
+ CONFIG_KEYS = %i[enabled version_updates save_trash].freeze
12
12
 
13
13
  VERSION_CLASS_SUFFIX = 'Version'
14
14
  private_constant :VERSION_CLASS_SUFFIX
@@ -36,7 +36,7 @@ module Hoardable
36
36
 
37
37
  @context = {}
38
38
  @config = CONFIG_KEYS.to_h do |key|
39
- [key, key != :return_everything]
39
+ [key, true]
40
40
  end
41
41
 
42
42
  class << self
@@ -75,6 +75,17 @@ module Hoardable
75
75
  @context = current_context
76
76
  end
77
77
 
78
+ # Allows performing a query for record states at a certain time. Returned {SourceModel}
79
+ # instances within the block may be {SourceModel} or {VersionModel} records.
80
+ #
81
+ # @param datetime [DateTime, Time] the datetime or time to temporally query records at
82
+ def at(datetime)
83
+ @at = datetime
84
+ yield
85
+ ensure
86
+ @at = nil
87
+ end
88
+
78
89
  # @!visibility private
79
90
  def logger
80
91
  @logger ||= ActiveSupport::TaggedLogging.new(Logger.new($stdout))
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- # This concern provides support for PostgreSQL’s tableoid system column to {SourceModel}.
5
- module Tableoid
4
+ # This concern provides support for PostgreSQL’s tableoid system column to {SourceModel} and
5
+ # temporal +ActiveRecord+ scopes.
6
+ module Scopes
6
7
  extend ActiveSupport::Concern
7
8
 
8
9
  TABLEOID_AREL_CONDITIONS = lambda do |arel_table, condition|
@@ -19,10 +20,10 @@ module Hoardable
19
20
 
20
21
  # By default {Hoardable} only returns instances of the parent table, and not the +versions+ in
21
22
  # 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
+ # the code in a `Hoardable.at(datetime)` block.
23
24
  default_scope do
24
- if hoardable_config[:return_everything]
25
- where(nil)
25
+ if (hoardable_at = Hoardable.instance_variable_get('@at'))
26
+ at(hoardable_at)
26
27
  else
27
28
  exclude_versions
28
29
  end
@@ -51,6 +52,22 @@ module Hoardable
51
52
  # Excludes +versions+ of the parent +ActiveRecord+ class. This is included by default in the
52
53
  # source model’s +default_scope+.
53
54
  scope :exclude_versions, -> { where(TABLEOID_AREL_CONDITIONS.call(arel_table, :eq)) }
55
+
56
+ # @!scope class
57
+ # @!method at
58
+ # @return [ActiveRecord<Object>]
59
+ #
60
+ # Returns instances of the source model and versions that were valid at the supplied
61
+ # +datetime+ or +time+, all cast as instances of the source model.
62
+ scope :at, lambda { |datetime|
63
+ raise(CreatedAtColumnMissingError, @klass.table_name) unless @klass.column_names.include?('created_at')
64
+
65
+ include_versions.where(id: version_class.at(datetime).select('id')).or(
66
+ exclude_versions
67
+ .where("#{table_name}.created_at < ?", datetime)
68
+ .where.not(id: version_class.select(:hoardable_source_id).where(DURING_QUERY, datetime))
69
+ ).hoardable
70
+ }
54
71
  end
55
72
 
56
73
  private
@@ -21,10 +21,15 @@ module Hoardable
21
21
  def version_class
22
22
  "#{name}#{VERSION_CLASS_SUFFIX}".constantize
23
23
  end
24
+
25
+ # Extends the current {SourceModel} scoping to include Hoardable’s {FinderMethods} overrides.
26
+ def hoardable
27
+ extending(FinderMethods)
28
+ end
24
29
  end
25
30
 
26
31
  included do
27
- include Tableoid
32
+ include Scopes
28
33
 
29
34
  around_update(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_VERSION_UPDATES]) do |_, block|
30
35
  hoardable_client.insert_hoardable_version('update', &block)
@@ -35,7 +40,7 @@ module Hoardable
35
40
  end
36
41
 
37
42
  before_destroy(if: HOARDABLE_CALLBACKS_ENABLED, unless: HOARDABLE_SAVE_TRASH) do
38
- versions.delete_all(:delete_all)
43
+ versions.delete_all
39
44
  end
40
45
 
41
46
  after_commit { hoardable_client.unset_hoardable_version_and_event_uuid }
@@ -48,35 +53,39 @@ module Hoardable
48
53
  inverse_of: :hoardable_source,
49
54
  foreign_key: :hoardable_source_id
50
55
  )
51
-
52
- # @!scope class
53
- # @!method at
54
- # @return [ActiveRecord<Object>]
55
- #
56
- # Returns instances of the source model and versions that were valid at the supplied
57
- # +datetime+ or +time+, all cast as instances of the source model.
58
- scope :at, lambda { |datetime|
59
- include_versions.where(id: version_class.at(datetime).select('id')).or(
60
- where.not(id: version_class.select(:hoardable_source_id).where(DURING_QUERY, datetime))
61
- )
62
- }
63
56
  end
64
57
 
65
- # Returns a boolean of whether the record is actually a trashed +version+.
58
+ # Returns a boolean of whether the record is actually a trashed +version+ cast as an instance of the
59
+ # source model.
66
60
  #
67
61
  # @return [Boolean]
68
62
  def trashed?
69
- versions.trashed.only_most_recent.first&.hoardable_source_foreign_id == id
63
+ versions.trashed.only_most_recent.first&.hoardable_source_id == id
64
+ end
65
+
66
+ # Returns a boolean of whether the record is actually a +version+ cast as an instance of the
67
+ # source model.
68
+ #
69
+ # @return [Boolean]
70
+ def version?
71
+ !!hoardable_client.hoardable_version_source_id
70
72
  end
71
73
 
72
- # Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
73
- # none. This will raise an error if you try to find a version in the future.
74
+ # Returns the +version+ at the supplied +datetime+ or +time+, or +self+ if there is none.
74
75
  #
75
76
  # @param datetime [DateTime, Time]
76
77
  def at(datetime)
78
+ version_at(datetime) || (self if created_at < datetime)
79
+ end
80
+
81
+ # Returns the +version+ at the supplied +datetime+ or +time+. This will raise an error if you
82
+ # try to find a version in the future.
83
+ #
84
+ # @param datetime [DateTime, Time]
85
+ def version_at(datetime)
77
86
  raise(Error, 'Future state cannot be known') if datetime.future?
78
87
 
79
- versions.at(datetime).first || self
88
+ versions.at(datetime).limit(1).first
80
89
  end
81
90
 
82
91
  # If a version is found at the supplied datetime, it will +revert!+ to it and return it. This
@@ -89,6 +98,15 @@ module Hoardable
89
98
  version.is_a?(version_class) ? version.revert! : self
90
99
  end
91
100
 
101
+ # Returns the +hoardable_source_id+ that represents the original {SourceModel} record’s ID. Will
102
+ # return nil if the current {SourceModel} record is not an instance of a {VersionModel} cast as
103
+ # {SourceModel}.
104
+ #
105
+ # @return [Integer, nil]
106
+ def hoardable_source_id
107
+ hoardable_client.hoardable_version_source_id || id
108
+ end
109
+
92
110
  delegate :version_class, to: :class
93
111
 
94
112
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- VERSION = '0.8.0'
4
+ VERSION = '0.10.1'
5
5
  end
@@ -113,17 +113,17 @@ module Hoardable
113
113
  _data&.dig('changes')
114
114
  end
115
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_id)
116
+ # Returns the ID of the {SourceModel} that created this {VersionModel}
117
+ def hoardable_source_id
118
+ read_attribute('hoardable_source_id')
119
119
  end
120
120
 
121
121
  private
122
122
 
123
123
  def insert_untrashed_source
124
124
  superscope = self.class.superclass.unscoped
125
- superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_foreign_id))
126
- superscope.find(hoardable_source_foreign_id)
125
+ superscope.insert(hoardable_source_attributes.merge('id' => hoardable_source_id))
126
+ superscope.find(hoardable_source_id)
127
127
  end
128
128
 
129
129
  def hoardable_source_attributes
data/lib/hoardable.rb CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  require_relative 'hoardable/version'
4
4
  require_relative 'hoardable/hoardable'
5
- require_relative 'hoardable/tableoid'
5
+ require_relative 'hoardable/finder_methods'
6
+ require_relative 'hoardable/scopes'
6
7
  require_relative 'hoardable/error'
7
8
  require_relative 'hoardable/database_client'
8
9
  require_relative 'hoardable/source_model'
@@ -10,3 +11,4 @@ require_relative 'hoardable/version_model'
10
11
  require_relative 'hoardable/model'
11
12
  require_relative 'hoardable/associations'
12
13
  require_relative 'generators/hoardable/migration_generator'
14
+ require_relative 'generators/hoardable/initializer_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, :return_everything, :warn_on_missing_created_at_column]
4
+ CONFIG_KEYS: [:enabled, :version_updates, :save_trash]
5
5
  VERSION_CLASS_SUFFIX: String
6
6
  VERSION_TABLE_SUFFIX: String
7
7
  DURING_QUERY: String
@@ -10,12 +10,14 @@ module Hoardable
10
10
  HOARDABLE_VERSION_UPDATES: ^(untyped) -> untyped
11
11
  self.@context: Hash[untyped, untyped]
12
12
  self.@config: untyped
13
+ self.@at: nil
13
14
  self.@logger: untyped
14
15
 
15
16
  def self.with: (untyped hash) -> untyped
17
+ def self.at: (untyped datetime) -> untyped
16
18
  def self.logger: -> untyped
17
19
 
18
- module Tableoid
20
+ module Scopes
19
21
  TABLEOID_AREL_CONDITIONS: Proc
20
22
 
21
23
  private
@@ -29,10 +31,14 @@ module Hoardable
29
31
  end
30
32
 
31
33
  class DatabaseClient
32
- attr_reader source_model: SourceModel
33
- def initialize: (SourceModel source_model) -> void
34
+ @hoardable_version_source_id: untyped
35
+
36
+ attr_reader source_record: SourceModel
37
+ def initialize: (SourceModel source_record) -> void
34
38
  def insert_hoardable_version: (untyped operation) -> untyped
35
39
  def find_or_initialize_hoardable_event_uuid: -> untyped
40
+ def hoardable_version_source_id: -> untyped
41
+ def query_hoardable_version_source_id: -> untyped
36
42
  def initialize_version_attributes: (untyped operation) -> untyped
37
43
  def initialize_temporal_range: -> Range
38
44
  def initialize_hoardable_data: -> untyped
@@ -40,33 +46,38 @@ module Hoardable
40
46
  def unset_hoardable_version_and_event_uuid: -> nil
41
47
  def previous_temporal_tsrange_end: -> untyped
42
48
  def hoardable_source_epoch: -> Time
43
- def maybe_warn_about_missing_created_at_column: -> nil
44
49
  end
45
50
 
46
51
  module SourceModel
47
- include Tableoid
52
+ include Scopes
48
53
  @hoardable_client: DatabaseClient
49
54
 
50
55
  attr_reader hoardable_version: untyped
51
56
  def trashed?: -> untyped
57
+ def version?: -> untyped
52
58
  def at: (untyped datetime) -> SourceModel
53
59
  def revert_to!: (untyped datetime) -> SourceModel?
60
+ def hoardable_source_id: -> untyped
54
61
 
55
62
  private
56
63
  def hoardable_client: -> DatabaseClient
57
64
 
58
65
  public
59
66
  def version_class: -> untyped
67
+ def hoardable: -> untyped
68
+
69
+ module FinderMethods
70
+ def find_one: (untyped id) -> untyped
71
+ end
60
72
  end
61
73
 
62
74
  module VersionModel
63
- @hoardable_source_foreign_id: untyped
64
75
  @hoardable_source_attributes: untyped
65
76
 
66
77
  def revert!: -> untyped
67
78
  def untrash!: -> untyped
68
79
  def changes: -> untyped
69
- def hoardable_source_foreign_id: -> untyped
80
+ def hoardable_source_id: -> untyped
70
81
 
71
82
  private
72
83
  def insert_untrashed_source: -> untyped
@@ -88,6 +99,17 @@ module Hoardable
88
99
 
89
100
  module Associations
90
101
  def belongs_to_trashable: (untyped name, ?nil scope, **untyped) -> untyped
102
+ def has_many_hoardable: (untyped name, ?nil scope, **untyped) -> untyped
103
+
104
+ module HasManyScope
105
+ @scope: untyped
106
+ @association: bot
107
+
108
+ def scope: -> untyped
109
+
110
+ private
111
+ def hoardable_scope: -> untyped
112
+ end
91
113
  end
92
114
 
93
115
  class MigrationGenerator
@@ -98,4 +120,8 @@ module Hoardable
98
120
  def migration_template_name: -> String
99
121
  def singularized_table_name: -> untyped
100
122
  end
123
+
124
+ class InitializerGenerator
125
+ def create_initializer_file: -> untyped
126
+ end
101
127
  end
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.8.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - justin talbott
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-10-01 00:00:00.000000000 Z
11
+ date: 2022-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -104,6 +104,7 @@ files:
104
104
  - LICENSE.txt
105
105
  - README.md
106
106
  - Rakefile
107
+ - lib/generators/hoardable/initializer_generator.rb
107
108
  - lib/generators/hoardable/migration_generator.rb
108
109
  - lib/generators/hoardable/templates/migration.rb.erb
109
110
  - lib/generators/hoardable/templates/migration_6.rb.erb
@@ -111,10 +112,11 @@ files:
111
112
  - lib/hoardable/associations.rb
112
113
  - lib/hoardable/database_client.rb
113
114
  - lib/hoardable/error.rb
115
+ - lib/hoardable/finder_methods.rb
114
116
  - lib/hoardable/hoardable.rb
115
117
  - lib/hoardable/model.rb
118
+ - lib/hoardable/scopes.rb
116
119
  - lib/hoardable/source_model.rb
117
- - lib/hoardable/tableoid.rb
118
120
  - lib/hoardable/version.rb
119
121
  - lib/hoardable/version_model.rb
120
122
  - sig/hoardable.rbs