hoardable 0.8.0 → 0.10.1

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