hoardable 0.12.9 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f56e0b270664927191148ea5ad8bcf4f4511c482c1964d4746f31513eb23b25f
4
- data.tar.gz: b69b35c7aa7f3eb013b1444c3b26115f012c8a7617ba457ce248074eca2706e0
3
+ metadata.gz: 83017b15a9b37931d02d7f5d8b7b8d3e81cab4b4cc4691f522430ae2c9be77f8
4
+ data.tar.gz: 1774bb8cfe7628bbc2bdd8293928dac45dce20c4724aa5c4bc435a4c59a40f0d
5
5
  SHA512:
6
- metadata.gz: 39c6176733cb67efc47e9d7985fa30427090e02c21c6abb6d54e9d2b88c975c430b4e0eafa4976457704a16c1fd8ef68c0245269f67adf0cf4a13d4e5a42c2f4
7
- data.tar.gz: 975229330ca851b4d847113a3e76bd0a0ff59a86cf39a5cfa73e11f456d475a0b51df82a90ab51a6035ab1f934ce7fa87607925679fd620c4dab25ec44979cf4
6
+ metadata.gz: d0b7d0024a9b8eee5982ab92af174a9a7a134445108c1c3d97945c5ad967f234743fe047f0c42726366d38b7facacc44bac530a5de9a5369de0a9e7bc27342f9
7
+ data.tar.gz: fb4d0de48b7d798e70c948aea4ae8adb90eaa2836c4ca2d76e952e5c02d53951205f2628bc3e620cb1824f6ac49fcc692dc19130f907c932e0ed3b78fee217e8
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 2.7
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
data/.tool-versions CHANGED
@@ -1,2 +1,2 @@
1
- ruby 3.1.2
1
+ ruby 3.2.1
2
2
  postgres 14.4
data/CHANGELOG.md CHANGED
@@ -1,4 +1,6 @@
1
- ## [Unreleased]
1
+ ## 0.14.0
2
2
 
3
- - Stability is coming.
3
+ - *Breaking Change* - Support for Ruby 2.6 is dropped
4
+ - Adjusts the migration and install generators to use the `fx` gem so that Rails 7+ can use `schema.rb`
5
+ instead of `structure.sql`
4
6
 
data/README.md CHANGED
@@ -1,23 +1,21 @@
1
1
  # Hoardable ![gem version](https://img.shields.io/gem/v/hoardable?style=flat-square)
2
2
 
3
- Hoardable is an ActiveRecord extension for Ruby 2.6+, Rails 6.1+, and PostgreSQL that allows for
4
- versioning and soft-deletion of records through the use of _uni-temporal inherited tables_.
3
+ Hoardable is an ActiveRecord extension for Ruby 2.7+, Rails 6.1+, and PostgreSQL that allows for versioning
4
+ and soft-deletion of records through the use of _uni-temporal inherited tables_.
5
5
 
6
- [Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern
7
- where each row of a table contains data along with one or more time ranges. In the case of this gem,
8
- each database row has a time range that represents the row’s valid time range - hence
9
- "uni-temporal".
6
+ [Temporal tables](https://en.wikipedia.org/wiki/Temporal_database) are a database design pattern where each
7
+ row of a table contains data along with one or more time ranges. In the case of this gem, each database row
8
+ has a time range that represents the row’s valid time range - hence "uni-temporal".
10
9
 
11
- [Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL
12
- that allows a table to inherit all columns of a parent table. The descendant table’s schema will
13
- stay in sync with its parent. If a new column is added to or removed from the parent, the schema
14
- change is reflected on its descendants.
10
+ [Table inheritance](https://www.postgresql.org/docs/14/ddl-inherit.html) is a feature of PostgreSQL that
11
+ allows a table to inherit all columns of a parent table. The descendant table’s schema will stay in sync with
12
+ its parent. If a new column is added to or removed from the parent, the schema change is reflected on its
13
+ descendants.
15
14
 
16
- With these concepts combined, `hoardable` offers a simple and effective model versioning system for
17
- Rails. Versions of records are stored in separate, inherited tables along with their valid time
18
- ranges and contextual data. Compared to other Rails-oriented versioning systems, this gem strives to
19
- be more explicit and obvious on the lower RDBS level while still familiar and convenient to use
20
- within Ruby on Rails.
15
+ With these concepts combined, `hoardable` offers a simple and effective model versioning system for Rails.
16
+ Versions of records are stored in separate, inherited tables along with their valid time ranges and
17
+ contextual data. Compared to other Rails-oriented versioning systems, this gem strives to be more explicit
18
+ and obvious on the lower database level, while still familiar and convenient to use within Ruby on Rails.
21
19
 
22
20
  [👉 Documentation](https://www.rubydoc.info/gems/hoardable)
23
21
 
@@ -36,19 +34,16 @@ bin/rails g hoardable:install
36
34
  bin/rails db:migrate
37
35
  ```
38
36
 
39
- This will generate PostgreSQL functions, an initiailzer, and set `config.active_record.schema_format
40
- = :sql` in `application.rb`.
37
+ This will generate PostgreSQL functions, an enum and an initiailzer. It will also set
38
+ `config.active_record.schema_format = :sql` in `application.rb` if you are using Rails < 7.
41
39
 
42
40
  ### Model Installation
43
41
 
44
- You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
45
- of:
42
+ You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions of:
46
43
 
47
44
  ```ruby
48
45
  class Post < ActiveRecord::Base
49
46
  include Hoardable::Model
50
- belongs_to :user
51
- has_many :comments, dependent: :destroy
52
47
  ...
53
48
  end
54
49
  ```
@@ -60,34 +55,31 @@ bin/rails g hoardable:migration Post
60
55
  bin/rails db:migrate
61
56
  ```
62
57
 
63
- By default, it will try to guess the foreign key type for the `_versions` table based on the primary
64
- key of the model specified in the migration generator above. If you want/need to specify this
65
- explicitly, you can do so:
58
+ By default, it will guess the foreign key type for the `_versions` table based on the primary key of the
59
+ model specified in the migration generator above. If you want/need to specify this explicitly, you can do so:
66
60
 
67
61
  ```
68
62
  bin/rails g hoardable:migration Post --foreign-key-type uuid
69
63
  ```
70
64
 
71
- _Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
72
- need to query versions often, you should add appropriate indexes to the `_versions` tables.
65
+ _Note:_ Creating an inherited table does not inherit the indexes from the parent table. If you need to query
66
+ versions often, you should add appropriate indexes to the `_versions` tables.
73
67
 
74
68
  ## Usage
75
69
 
76
70
  ### Overview
77
71
 
78
- Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass
79
- of that model. As we continue our example from above:
72
+ Once you include `Hoardable::Model` into a model, it will dynamically generate a "Version" subclass of that
73
+ model. As we continue our example from above:
80
74
 
81
- ```
82
- $ irb
83
- >> Post
84
- => Post(id: integer, body: text, user_id: integer, created_at: datetime, hoardable_id: integer)
85
- >> PostVersion
86
- => PostVersion(id: integer, body: text, user_id: integer, created_at: datetime, hoardable_id: integer, _data: jsonb, _during: tsrange)
75
+ ```ruby
76
+ Post #=> Post(id: integer, created_at: datetime, updated_at: datetime, hoardable_id: integer)
77
+ PostVersion #=> PostVersion(id: integer, created_at: datetime, updated_at: datetime, hoardable_id: integer, _data: jsonb, _during: tsrange, _event_uuid: uuid, _operation: enum)
78
+ Post.version_class #=> same as `PostVersion`
87
79
  ```
88
80
 
89
- A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion
90
- of a `Post` occurs, a version is created:
81
+ A `Post` now `has_many :versions`. With the default configuration, whenever an update and deletion of a
82
+ `Post` occurs, a version is created:
91
83
 
92
84
  ```ruby
93
85
  post = Post.create!(title: "Title")
@@ -102,7 +94,11 @@ Post.find(post.id) # raises ActiveRecord::RecordNotFound
102
94
  ```
103
95
 
104
96
  Each `PostVersion` has access to the same attributes, relationships, and other model behavior that
105
- `Post` has, but as a read-only record.
97
+ `Post` has, but as a read-only record:
98
+
99
+ ``` ruby
100
+ post.versions.last.update!(title: "Rewrite history") #=> raises ActiveRecord::ReadOnlyRecord
101
+ ```
106
102
 
107
103
  If you ever need to revert to a specific version, you can call `version.revert!` on it.
108
104
 
@@ -113,8 +109,8 @@ post.reload.versions.last.revert!
113
109
  post.title # => "Title"
114
110
  ```
115
111
 
116
- If you would like to untrash a specific version, you can call `version.untrash!` on it. This will
117
- re-insert the model in the parent class’s table with the original primary key.
112
+ If you would like to untrash a specific version of a record you deleted, you can call `version.untrash!` on
113
+ it. This will re-insert the model in the parent class’s table with the original primary key.
118
114
 
119
115
  ```ruby
120
116
  post = Post.create!(title: "Title")
@@ -128,12 +124,17 @@ trashed_post.untrash!
128
124
  Post.find(post.id) # #<Post>
129
125
  ```
130
126
 
127
+ _Note:_ You will notice above that both `posts` and `post_versions` pull from the same ID sequence. This
128
+ allows for uniquely identifying source records and versions when results are mixed together. Both a source
129
+ record and versions have an automatically managed `hoardable_id` that always represents the primary key value
130
+ of the original source record.
131
+
131
132
  ### Querying and Temporal Lookup
132
133
 
133
134
  Since a `PostVersion` is an `ActiveRecord` class, you can query them like another model resource:
134
135
 
135
136
  ```ruby
136
- post.versions.where(user_id: Current.user.id, body: "Cool!")
137
+ post.versions.where(state: :draft)
137
138
  ```
138
139
 
139
140
  If you want to look-up the version of a record at a specific time, you can use the `.at` method:
@@ -141,7 +142,8 @@ If you want to look-up the version of a record at a specific time, you can use t
141
142
  ```ruby
142
143
  post.at(1.day.ago) # => #<PostVersion>
143
144
  # or you can use the scope on the version model class
144
- PostVersion.at(1.day.ago).find_by(hoardable_id: post.id) # => #<PostVersion>
145
+ post.versions.at(1.day.ago) # => #<PostVersion>
146
+ PostVersion.at(1.day.ago).find_by(hoardable_id: post.id) # => same as above
145
147
  ```
146
148
 
147
149
  The source model class also has an `.at` method:
@@ -150,35 +152,32 @@ The source model class also has an `.at` method:
150
152
  Post.at(1.day.ago) # => [#<Post>, #<Post>]
151
153
  ```
152
154
 
153
- This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were
154
- valid at that time, all cast as instances of `Post`.
155
+ This will return an ActiveRecord scoped query of all `Post` and `PostVersion` records that were valid at that
156
+ time, all cast as instances of `Post`.
155
157
 
156
- There is also an `at` method on `Hoardable` itself for more complex temporal resource querying. See
157
- [Relationships](#relationships) for more.
158
+ There is also an `at` method on `Hoardable` itself for more complex and experimental temporal resource
159
+ querying. See [Relationships](#relationships) for more.
158
160
 
159
- By default, `hoardable` will keep copies of records you have destroyed. You can query them
160
- specifically with:
161
+ By default, `hoardable` will keep copies of records you have destroyed. You can query them specifically with:
161
162
 
162
163
  ```ruby
163
164
  PostVersion.trashed
164
- Post.version_class.trashed # <- same thing as above
165
+ Post.version_class.trashed # <- same as above
165
166
  ```
166
167
 
167
- _Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
168
- beginning of the first temporal period, you will need to ensure the source model table has a
169
- `created_at` timestamp column. If this is missing, an error will be raised.
168
+ _Note:_ A `Version` is not created upon initial parent model creation. To accurately track the beginning of
169
+ the first temporal period, you will need to ensure the source model table has a `created_at` timestamp
170
+ column. If this is missing, an error will be raised.
170
171
 
171
172
  ### Tracking Contextual Data
172
173
 
173
- You’ll often want to track contextual data about the creation of a version. There are 3 optional
174
- symbol keys that are provided for tracking contextual information:
174
+ You’ll often want to track contextual data about the creation of a version. There are 2 options that can be
175
+ provided for tracking contextual information:
175
176
 
176
177
  - `:whodunit` - an identifier for who is responsible for creating the version
177
- - `:note` - a description regarding the versioning
178
178
  - `:meta` - any other contextual information you’d like to store along with the version
179
179
 
180
- This information is stored in a `jsonb` column. Each key’s value can be in the format of your
181
- choosing.
180
+ This information is stored in a `jsonb` column. Each key’s value can be in the format of your choosing.
182
181
 
183
182
  One convenient way to assign contextual data to these is by defining a proc in an initializer, i.e.:
184
183
 
@@ -192,17 +191,17 @@ post.update!(status: 'live')
192
191
  post.reload.versions.last.hoardable_whodunit # => 123
193
192
  ```
194
193
 
195
- You can also set this context manually as well, just remember to clear them afterwards.
194
+ You can also set this context manually as well:
196
195
 
197
196
  ```ruby
198
- Hoardable.note = "reverting due to accidental deletion"
197
+ Hoardable.meta = { note: "reverting due to accidental deletion" }
199
198
  post.update!(title: "We’re back!")
200
- Hoardable.note = nil
201
- post.reload.versions.last.hoardable_note # => "reverting due to accidental deletion"
199
+ Hoardable.meta = nil
200
+ post.reload.versions.last.hoardable_meta['note'] # => "reverting due to accidental deletion"
202
201
  ```
203
202
 
204
- A more useful pattern is to use `Hoardable.with` to set the context around a block. A good example
205
- of this would be in `ApplicationController`:
203
+ A more useful pattern however is to use `Hoardable.with` to set the context around a block. For example, you
204
+ could have the following in your `ApplicationController`:
206
205
 
207
206
  ```ruby
208
207
  class ApplicationController < ActionController::Base
@@ -220,9 +219,9 @@ end
220
219
  ```
221
220
 
222
221
  `hoardable` will also automatically capture the ActiveRecord
223
- [changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the
224
- `operation` that cause the version (`update` or `delete`), and it will also tag all versions created
225
- in the same database transaction with a shared and unique `event_uuid`. These are available as:
222
+ [changes](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html#method-i-changes) hash, the `operation`
223
+ that cause the version (`update` or `delete`), and it will also tag all versions created in the same database
224
+ transaction with a shared and unique `event_uuid` for that transaction. These are available as:
226
225
 
227
226
  ```ruby
228
227
  version.changes
@@ -232,12 +231,12 @@ version.hoardable_event_uuid
232
231
 
233
232
  ### Model Callbacks
234
233
 
235
- Sometimes you might want to do something with a version after it gets inserted to the database. You
236
- can access it in `after_versioned` callbacks on the source record as `hoardable_version`. These
237
- happen within `ActiveRecord`’s `.save`, which is enclosed in an ActiveRecord transaction.
234
+ Sometimes you might want to do something with a version after it gets inserted to the database. You can
235
+ access it in `after_versioned` callbacks on the source record as `hoardable_version`. These happen within
236
+ `ActiveRecord`’s `.save`, which is enclosed in an ActiveRecord transaction.
238
237
 
239
- There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called
240
- on the source record after a version is reverted or untrashed.
238
+ There are also `after_reverted` and `after_untrashed` callbacks available as well, which are called on the
239
+ source record after a version is reverted or untrashed.
241
240
 
242
241
  ```ruby
243
242
  class User
@@ -272,11 +271,11 @@ Hoardable.version_updates # => default true
272
271
  Hoardable.save_trash # => default true
273
272
  ```
274
273
 
275
- `Hoardable.enabled` controls whether versions will be ever be created.
274
+ `Hoardable.enabled` globally controls whether versions will be ever be created.
276
275
 
277
- `Hoardable.version_updates` controls whether versions get created on record updates.
276
+ `Hoardable.version_updates` globally controls whether versions get created on record updates.
278
277
 
279
- `Hoardable.save_trash` controls whether to create versions upon record deletion. When this is set to
278
+ `Hoardable.save_trash` globally controls whether to create versions upon record deletion. When this is set to
280
279
  `false`, all versions of a record will be deleted when the record is destroyed.
281
280
 
282
281
  If you would like to temporarily set a config setting, you can use `Hoardable.with`:
@@ -287,7 +286,7 @@ Hoardable.with(enabled: false) do
287
286
  end
288
287
  ```
289
288
 
290
- You can also configure these variables per `ActiveRecord` class as well using `hoardable_config`:
289
+ You can also configure these settings per `ActiveRecord` class using `hoardable_config`:
291
290
 
292
291
  ```ruby
293
292
  class Comment < ActiveRecord::Base
@@ -305,18 +304,17 @@ Comment.with_hoardable_config(version_updates: true) do
305
304
  end
306
305
  ```
307
306
 
308
- If a model-level option exists, it will use that. Otherwise, it will fall back to the global
309
- `Hoardable` config.
307
+ If a model-level option exists, it will use that. Otherwise, it will fall back to the global `Hoardable`
308
+ config.
310
309
 
311
- ### Relationships
310
+ ## Relationships
312
311
 
313
- As in life, sometimes relationships can be hard, but here are some pointers on handling associations
314
- with `Hoardable` considerations.
312
+ ### Belongs To Trashable
315
313
 
316
- Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
317
- record’s foreign key will point to the non-existent trashed version of the parent. If you would like
318
- to have `belongs_to` resolve to the trashed parent model in this case, you can give it the option of
319
- `trashable: true`:
314
+ Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child record’s
315
+ foreign key will point to the non-existent trashed version of the parent. If you would like to have
316
+ `belongs_to` resolve to the trashed parent model in this case, you can give it the option of `trashable:
317
+ true`:
320
318
 
321
319
  ```ruby
322
320
  class Comment
@@ -325,10 +323,11 @@ class Comment
325
323
  end
326
324
  ```
327
325
 
328
- Sometimes you'll have a Hoardable record that `has_many` other Hoardable records and you will want
329
- to know the state of both the parent record and the children at a cetain point in time. You
330
- accomplish this by adding `hoardable: true` to the `has_many` relationship and using the
331
- `Hoardable.at` method:
326
+ ### Hoardable Has Many & Has One
327
+
328
+ Sometimes you'll have a Hoardable record that `has_one` or `has_many` other Hoardable records and you will
329
+ want to know the state of both the parent record and the children at a cetain point in time. You accomplish
330
+ this by adding `hoardable: true` to the `has_many` relationship and using the `Hoardable.at` method:
332
331
 
333
332
  ```ruby
334
333
  class Post
@@ -358,22 +357,26 @@ Hoardable.at(datetime) do
358
357
  end
359
358
  ```
360
359
 
361
- There are some additional details to point out above. Firstly, it is important to note that the
362
- final `post.id` yields a different value than the originally created `Post`. This is because the
363
- `post` within the `#at` block is actually a temporal version, since it has been subsequently
364
- updated, but it has been reified as a `Post` for the purposes of your business logic (serialization,
365
- rendering views, exporting, etc). Don’t fret - you will not be able to commit any updates to the
366
- version, even though it is masquerading as a `Post`.
360
+ There are some additional details to point out above. Firstly, it is important to note that the final
361
+ `post.id` yields a different value than the originally created `Post`. This is because the `post` within the
362
+ `#at` block is actually a temporal version, since it has been subsequently updated, but is reified as a
363
+ `Post` for the purposes of your business logic (serialization, rendering views, exporting, etc). Don’t fret -
364
+ you will not be able to commit any updates to the version, even though it is masquerading as a `Post` because
365
+ a database trigger won’t allow you to.
367
366
 
368
- If you are ever unsure if a Hoardable record is a "source" or a "version", you can be sure by
369
- calling `version?` on it. If you want to get the true original source record ID, you can call
370
- `hoardable_id`.
367
+ If you are ever unsure if a Hoardable record is a source record or a version, you can be sure by calling
368
+ `version?` on it. If you want to get the true original source record ID, you can call `hoardable_id`.
371
369
 
372
- Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and want
373
- to untrash everything in a similar dependent manner. Whenever a hoardable version is created in a
374
- database transaction, it will create or re-use a unique event UUID for that transaction and tag all
375
- versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
376
- records that were trashed with it:
370
+ _Note:_ `Hoardable.at` is still very experimental and is potentially not very performant for querying large
371
+ data sets.
372
+
373
+ ### Cascading Untrashing
374
+
375
+ Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and if you untrash the parent
376
+ record, you’ll want to also untrash the children. Whenever a hoardable version is created in a database
377
+ transaction, it will create or re-use a unique event UUID for the current database transaction and tag all
378
+ versions created with it. That way, when you `untrash!` a record, you could find and `untrash!` records that
379
+ were trashed with it:
377
380
 
378
381
  ```ruby
379
382
  class Post < ActiveRecord::Base
@@ -392,18 +395,17 @@ end
392
395
 
393
396
  ### Action Text
394
397
 
395
- Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a
396
- temporal table for `ActionText::RichText`:
398
+ Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a temporal
399
+ table for `ActionText::RichText`:
397
400
 
398
401
  ```
399
402
  bin/rails g hoardable:migration ActionText::RichText
400
403
  bin/rails db:migrate
401
404
  ```
402
405
 
403
- Then in your model, include `Hoardable::Model` and provide the `hoardable: true` keyword to
404
- `has_rich_text`:
406
+ Then in your model include `Hoardable::Model` and provide the `hoardable: true` keyword to `has_rich_text`:
405
407
 
406
- ``` ruby
408
+ ```ruby
407
409
  class Post < ActiveRecord::Base
408
410
  include Hoardable::Model # or `Hoardable::Associations` if you don't need `PostVersion`
409
411
  has_rich_text :content, hoardable: true
@@ -412,7 +414,7 @@ end
412
414
 
413
415
  Now the `rich_text_content` relationship will be managed as a Hoardable `has_one` relationship:
414
416
 
415
- ``` ruby
417
+ ```ruby
416
418
  post = Post.create!(content: '<div>Hello World</div>')
417
419
  datetime = DateTime.current
418
420
  post.update!(content: '<div>Goodbye Cruel World</div>')
@@ -423,64 +425,61 @@ Hoardable.at(datetime) do
423
425
  end
424
426
  ```
425
427
 
426
- ### Known Gotchas
428
+ ## Known Gotchas
427
429
 
428
- #### Rails Fixtures
430
+ ### Rails Fixtures
429
431
 
430
432
  Rails uses a method called
431
433
  [`disable_referential_integrity`](https://github.com/rails/rails/blob/06e9fbd954ab113108a7982357553fdef285bff1/activerecord/lib/active_record/connection_adapters/postgresql/referential_integrity.rb#L7)
432
- when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies
433
- on for assigning `hoardable_id` from the primary key’s value. If you would still like to use
434
- fixtures, you must specify the primary key’s value and `hoardable_id` to the same identifier value
435
- in the fixture. This is not an issue with fixture replacement libraries like `factory_girl` or
434
+ when inserting fixtures into the database. This disables PostgreSQL triggers, which Hoardable relies on for
435
+ assigning `hoardable_id` from the primary key’s value. If you would still like to use fixtures, you must
436
+ specify the primary key’s value and `hoardable_id` to the same identifier value in the fixture. This is not
437
+ an issue with fixture replacement libraries like `factory_bot` or
436
438
  [`world_factory`](https://github.com/FutureProofRetail/world_factory) however.
437
439
 
438
440
  ## Gem Comparison
439
441
 
440
442
  #### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
441
443
 
442
- `paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
443
- database types than PostgeSQL and (by default) stores all versions of all versioned models in a
444
- single `versions` table. It stores changes in a `text`, `json`, or `jsonb` column. In order to
445
- efficiently query the `versions` table, a `jsonb` column should be used, which takes up a lot of
446
- space to index. Unless you customize your configuration, all `versions` for all models types are
447
- in the same table which is inefficient if you are only interested in querying versions of a single
448
- model. By contrast, `hoardable` stores versions in smaller, isolated and inherited tables with the
449
- same database columns as their parents, which are more efficient for querying as well as auditing
450
- for truncating and dropping. The concept of a `temporal` time-frame does not exist for a single
451
- version since there is only a `created_at` timestamp.
444
+ `paper_trail` is maybe the most popular and fully featured gem in this space. It works for other database
445
+ types than PostgeSQL and (by default) stores all versions of all versioned models in a single `versions`
446
+ table. It stores changes in a `text`, `json`, or `jsonb` column. In order to efficiently query the `versions`
447
+ table, a `jsonb` column should be used, which takes up a lot of space to index. Unless you customize your
448
+ configuration, all `versions` for all models types are in the same table which is inefficient if you are only
449
+ interested in querying versions of a single model. By contrast, `hoardable` stores versions in smaller,
450
+ isolated and inherited tables with the same database columns as their parents, which are more efficient for
451
+ querying as well as auditing for truncating and dropping. The concept of a `temporal` time-frame does not
452
+ exist for a single version since there is only a `created_at` timestamp.
452
453
 
453
454
  #### [`audited`](https://github.com/collectiveidea/audited)
454
455
 
455
- `audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in
456
- a single table, you must opt into using `jsonb` as the column type to store "changes", in case you
457
- want to query them, and there is no concept of a `temporal` time-frame for a single version. It
458
- makes opinionated decisions about contextual data requirements and stores them as top level data
459
- types on the `audited` table.
456
+ `audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in a single
457
+ table, you must opt into using `jsonb` as the column type to store "changes", in case you want to query them,
458
+ and there is no concept of a `temporal` time-frame for a single version. It makes opinionated decisions about
459
+ contextual data requirements and stores them as top level data types on the `audited` table.
460
460
 
461
461
  #### [`discard`](https://github.com/jhawthorn/discard)
462
462
 
463
- `discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through
464
- the time-stamping of a `discarded_at` column on the records table; there is no other capturing of
465
- the event that caused the soft deletion unless you implement it yourself. Once the "discarded"
466
- record is restored, the previous "discarded" awareness is lost. Since "discarded" records exist in
467
- the same table as "undiscarded" records, you must explicitly omit the discarded records from queries
468
- across your app to keep them from leaking in.
463
+ `discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through the
464
+ time-stamping of a `discarded_at` column on the records table; there is no other capturing of the event that
465
+ caused the soft deletion unless you implement it yourself. Once the "discarded" record is restored, the
466
+ previous "discarded" awareness is lost. Since "discarded" records exist in the same table as "undiscarded"
467
+ records, you must explicitly omit the discarded records from queries across your app to keep them from
468
+ leaking in.
469
469
 
470
470
  #### [`paranoia`](https://github.com/rubysherpas/paranoia)
471
471
 
472
- `paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead
473
- of `paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods.
474
- `hoardable` employs callbacks to create trashed versions instead of overriding methods. Otherwise,
475
- `paranoia` works similarly to `discard` in that it keeps deleted records in the same table and tags
476
- them with a `deleted_at` timestamp. No other information about the soft-deletion event is stored.
472
+ `paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead of
473
+ `paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods. `hoardable`
474
+ employs callbacks to create trashed versions instead of overriding methods. Otherwise, `paranoia` works
475
+ similarly to `discard` in that it keeps deleted records in the same table and tags them with a `deleted_at`
476
+ timestamp. No other information about the soft-deletion event is stored.
477
477
 
478
478
  #### [`logidze`](https://github.com/palkan/logidze)
479
479
 
480
- `logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.
481
- Instead of storing the previous versions or changes in a separate table, it stores them in a
482
- proprietary JSON format directly on the database row of the record itself. If does not support soft
483
- deletion.
480
+ `logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers. Instead
481
+ of storing the previous versions or changes in a separate table, it stores them in a proprietary JSON format
482
+ directly on the database row of the record itself. If does not support soft deletion.
484
483
 
485
484
  ## Contributing
486
485
 
@@ -0,0 +1,8 @@
1
+ CREATE OR REPLACE FUNCTION hoardable_prevent_update_id() RETURNS trigger
2
+ LANGUAGE plpgsql AS
3
+ $$BEGIN
4
+ IF NEW.hoardable_id <> OLD.hoardable_id THEN
5
+ RAISE EXCEPTION 'hoardable id cannot be updated';
6
+ END IF;
7
+ RETURN NEW;
8
+ END;$$;
@@ -0,0 +1,18 @@
1
+ CREATE OR REPLACE FUNCTION hoardable_source_set_id() RETURNS trigger
2
+ LANGUAGE plpgsql AS
3
+ $$
4
+ DECLARE
5
+ _pk information_schema.constraint_column_usage.column_name%TYPE;
6
+ _id _pk%TYPE;
7
+ BEGIN
8
+ SELECT c.column_name
9
+ FROM information_schema.table_constraints t
10
+ JOIN information_schema.constraint_column_usage c
11
+ ON c.constraint_name = t.constraint_name
12
+ WHERE c.table_name = TG_TABLE_NAME AND t.constraint_type = 'PRIMARY KEY'
13
+ LIMIT 1
14
+ INTO _pk;
15
+ EXECUTE format('SELECT $1.%I', _pk) INTO _id USING NEW;
16
+ NEW.hoardable_id = _id;
17
+ RETURN NEW;
18
+ END;$$;
@@ -0,0 +1,6 @@
1
+ CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
2
+ LANGUAGE plpgsql AS
3
+ $$BEGIN
4
+ RAISE EXCEPTION 'updating a version is not allowed';
5
+ RETURN NEW;
6
+ END;$$;
@@ -8,6 +8,7 @@ module Hoardable
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  source_root File.expand_path('templates', __dir__)
10
10
  include Rails::Generators::Migration
11
+ delegate :supports_schema_enums?, to: :class
11
12
 
12
13
  def create_initializer_file
13
14
  create_file(
@@ -22,12 +23,25 @@ module Hoardable
22
23
  )
23
24
  end
24
25
 
26
+ def change_schema_format_to_sql
27
+ return if supports_schema_enums?
28
+
29
+ application 'config.active_record.schema_format = :sql'
30
+ end
31
+
25
32
  def create_migration_file
26
33
  migration_template 'install.rb.erb', 'db/migrate/install_hoardable.rb'
27
34
  end
28
35
 
29
- def change_schema_format_to_sql
30
- application 'config.active_record.schema_format = :sql'
36
+ def create_functions
37
+ Dir.glob(File.join(__dir__, 'functions', '*.sql')).each do |file_path|
38
+ file_name = file_path.match(%r{([^/]+)\.sql})[1]
39
+ template file_path, "db/functions/#{file_name}_v01.sql"
40
+ end
41
+ end
42
+
43
+ def self.supports_schema_enums?
44
+ ActiveRecord.version >= ::Gem::Version.new('7.0.0')
31
45
  end
32
46
 
33
47
  def self.next_migration_number(dir)
@@ -9,12 +9,30 @@ module Hoardable
9
9
  class MigrationGenerator < ActiveRecord::Generators::Base
10
10
  source_root File.expand_path('templates', __dir__)
11
11
  include Rails::Generators::Migration
12
- class_option :foreign_key_type, type: :string
12
+ class_option(
13
+ :foreign_key_type,
14
+ type: :string,
15
+ optional: true,
16
+ desc: 'explictly set / override the foreign key type of the versions table'
17
+ )
13
18
 
14
19
  def create_versions_table
15
20
  migration_template 'migration.rb.erb', "db/migrate/create_#{singularized_table_name}_versions.rb"
16
21
  end
17
22
 
23
+ def create_triggers
24
+ {
25
+ versions_prevent_update: singularized_table_name,
26
+ set_hoardable_id: table_name,
27
+ prevent_update_hoardable_id: table_name
28
+ }.each do |(trigger_name, trigger_table_name)|
29
+ template(
30
+ "../triggers/#{trigger_name}.sql",
31
+ "db/triggers/#{trigger_table_name}_#{trigger_name}_v01.sql"
32
+ )
33
+ end
34
+ end
35
+
18
36
  no_tasks do
19
37
  def foreign_key_type
20
38
  options[:foreign_key_type] ||
@@ -1,65 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class InstallHoardable < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
- def up
5
- execute(
6
- <<~SQL.squish
7
- DO $$
8
- BEGIN
9
- IF NOT EXISTS (
10
- SELECT 1 FROM pg_type t WHERE t.typname = 'hoardable_operation'
11
- ) THEN
12
- CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
13
- END IF;
14
- END
15
- $$;
4
+ def change
5
+ create_function :hoardable_prevent_update_id
6
+ create_function :hoardable_source_set_id
7
+ create_function :hoardable_version_prevent_update
8
+ <% if supports_schema_enums? %>
9
+ create_enum :hoardable_operation, %w[update delete insert]
10
+ <% else %>
11
+ reversible do |dir|
12
+ dir.up do
13
+ execute(
14
+ <<~SQL.squish
15
+ DO $$
16
+ BEGIN
17
+ IF NOT EXISTS (
18
+ SELECT 1 FROM pg_type t WHERE t.typname = 'hoardable_operation'
19
+ ) THEN
20
+ CREATE TYPE hoardable_operation AS ENUM ('update', 'delete', 'insert');
21
+ END IF;
22
+ END
23
+ $$;
24
+ SQL
25
+ )
26
+ end
16
27
 
17
- CREATE OR REPLACE FUNCTION hoardable_source_set_id() RETURNS trigger
18
- LANGUAGE plpgsql AS
19
- $$
20
- DECLARE
21
- _pk information_schema.constraint_column_usage.column_name%TYPE;
22
- _id _pk%TYPE;
23
- BEGIN
24
- SELECT c.column_name
25
- FROM information_schema.table_constraints t
26
- JOIN information_schema.constraint_column_usage c
27
- ON c.constraint_name = t.constraint_name
28
- WHERE c.table_name = TG_TABLE_NAME AND t.constraint_type = 'PRIMARY KEY'
29
- LIMIT 1
30
- INTO _pk;
31
- EXECUTE format('SELECT $1.%I', _pk) INTO _id USING NEW;
32
- NEW.hoardable_id = _id;
33
- RETURN NEW;
34
- END;$$;
35
-
36
- CREATE OR REPLACE FUNCTION hoardable_prevent_update_id() RETURNS trigger
37
- LANGUAGE plpgsql AS
38
- $$BEGIN
39
- IF NEW.hoardable_id <> OLD.hoardable_id THEN
40
- RAISE EXCEPTION 'hoardable id cannot be updated';
41
- END IF;
42
- RETURN NEW;
43
- END;$$;
44
-
45
- CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
46
- LANGUAGE plpgsql AS
47
- $$BEGIN
48
- RAISE EXCEPTION 'updating a version is not allowed';
49
- RETURN NEW;
50
- END;$$;
51
- SQL
52
- )
53
- end
54
-
55
- def down
56
- execute(
57
- <<~SQL.squish
58
- DROP TYPE IF EXISTS hoardable_operation;
59
- DROP FUNCTION IF EXISTS hoardable_version_prevent_update();
60
- DROP FUNCTION IF EXISTS hoardable_source_set_id();
61
- DROP FUNCTION IF EXISTS hoardable_prevent_update_id();
62
- SQL
63
- )
28
+ dir.down do
29
+ execute('DROP TYPE IF EXISTS hoardable_operation;')
30
+ end
31
+ end
32
+ <% end %>
64
33
  end
65
34
  end
@@ -12,34 +12,15 @@ class Create<%= class_name.singularize.delete(':') %>Versions < ActiveRecord::Mi
12
12
  end
13
13
  reversible do |dir|
14
14
  dir.up do
15
- execute(
16
- <<~SQL
17
- UPDATE <%= table_name %> SET hoardable_id = <%= primary_key %>;
18
- CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
19
- BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
20
- EXECUTE PROCEDURE hoardable_version_prevent_update();
21
- CREATE TRIGGER <%= table_name %>_set_hoardable_id
22
- BEFORE INSERT ON <%= table_name %> FOR EACH ROW
23
- EXECUTE PROCEDURE hoardable_source_set_id();
24
- CREATE TRIGGER <%= table_name %>_prevent_update_hoardable_id
25
- BEFORE UPDATE ON <%= table_name %> FOR EACH ROW
26
- EXECUTE PROCEDURE hoardable_prevent_update_id();
27
- SQL
28
- )
29
- end
30
- dir.down do
31
- execute(
32
- <<~SQL
33
- DROP TRIGGER <%= singularized_table_name %>_versions_prevent_update
34
- ON <%= singularized_table_name %>_versions;
35
- DROP TRIGGER <%= table_name %>_set_hoardable_id
36
- ON <%= table_name %>;
37
- DROP TRIGGER <%= table_name %>_prevent_update_hoardable_id
38
- ON <%= table_name %>;
39
- SQL
40
- )
15
+ execute('UPDATE <%= table_name %> SET hoardable_id = <%= primary_key %>;')
41
16
  end
42
17
  end
18
+ create_trigger(
19
+ :<%= singularized_table_name %>_versions_prevent_update,
20
+ on: :<%= singularized_table_name %>_versions
21
+ )
22
+ create_trigger :<%= table_name %>_set_hoardable_id, on: :<%= table_name %>
23
+ create_trigger :<%= table_name %>_prevent_update_hoardable_id, on: :<%= table_name %>
43
24
  change_column_null :<%= table_name %>, :hoardable_id, false
44
25
  add_index :<%= singularized_table_name %>_versions, :<%= primary_key %>, unique: true
45
26
  add_index :<%= singularized_table_name %>_versions, :hoardable_id
@@ -0,0 +1,3 @@
1
+ CREATE TRIGGER <%= table_name %>_prevent_update_hoardable_id
2
+ BEFORE UPDATE ON <%= table_name %> FOR EACH ROW
3
+ EXECUTE PROCEDURE hoardable_prevent_update_id();
@@ -0,0 +1,3 @@
1
+ CREATE TRIGGER <%= table_name %>_set_hoardable_id
2
+ BEFORE INSERT ON <%= table_name %> FOR EACH ROW
3
+ EXECUTE PROCEDURE hoardable_source_set_id();
@@ -0,0 +1,3 @@
1
+ CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
2
+ BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
3
+ EXECUTE PROCEDURE hoardable_version_prevent_update();
@@ -55,7 +55,7 @@ module Hoardable
55
55
  end
56
56
 
57
57
  def source_attributes_without_primary_key
58
- source_record.attributes_before_type_cast.without(source_primary_key, *generated_column_names).merge(
58
+ source_record.attributes.without(source_primary_key, *generated_column_names).merge(
59
59
  source_record.class.select(refreshable_column_names).find(source_record.id).slice(refreshable_column_names)
60
60
  )
61
61
  end
@@ -4,7 +4,7 @@
4
4
  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
- DATA_KEYS = %i[meta whodunit note event_uuid].freeze
7
+ DATA_KEYS = %i[meta whodunit event_uuid].freeze
8
8
 
9
9
  # Symbols for use with setting {Hoardable} configuration. See {file:README.md#configuration
10
10
  # README} for more.
@@ -73,7 +73,7 @@ module Hoardable
73
73
  private
74
74
 
75
75
  def tableoid
76
- connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
76
+ @tableoid ||= connection.execute("SELECT oid FROM pg_class WHERE relname = '#{table_name}'")[0]['oid']
77
77
  end
78
78
  end
79
79
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- VERSION = '0.12.9'
4
+ VERSION = '0.14.0'
5
5
  end
@@ -130,7 +130,7 @@ module Hoardable
130
130
  end
131
131
 
132
132
  def hoardable_source_attributes
133
- attributes_before_type_cast.without(
133
+ attributes.without(
134
134
  (self.class.column_names - self.class.superclass.column_names) +
135
135
  (SUPPORTS_VIRTUAL_COLUMNS ? self.class.columns.select(&:virtual?).map(&:name) : [])
136
136
  )
data/sig/hoardable.rbs CHANGED
@@ -1,6 +1,6 @@
1
1
  module Hoardable
2
2
  VERSION: String
3
- DATA_KEYS: [:meta, :whodunit, :note, :event_uuid]
3
+ DATA_KEYS: [:meta, :whodunit, :event_uuid]
4
4
  CONFIG_KEYS: [:enabled, :version_updates, :save_trash]
5
5
  VERSION_CLASS_SUFFIX: String
6
6
  VERSION_TABLE_SUFFIX: String
@@ -57,7 +57,7 @@ module Hoardable
57
57
  def source_attributes_without_primary_key: -> untyped
58
58
  def initialize_temporal_range: -> Range
59
59
  def initialize_hoardable_data: -> untyped
60
- def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
60
+ def assign_hoardable_context: (:event_uuid | :meta | :whodunit key) -> nil
61
61
  def unset_hoardable_version_and_event_uuid: -> nil
62
62
  def previous_temporal_tsrange_end: -> untyped
63
63
  def hoardable_source_epoch: -> untyped
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.12.9
4
+ version: 0.14.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-10-23 00:00:00.000000000 Z
11
+ date: 2023-03-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -70,6 +70,26 @@ dependencies:
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
72
  version: '8'
73
+ - !ruby/object:Gem::Dependency
74
+ name: fx
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0.8'
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '1'
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0.8'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '1'
73
93
  - !ruby/object:Gem::Dependency
74
94
  name: pg
75
95
  requirement: !ruby/object:Gem::Requirement
@@ -104,10 +124,16 @@ files:
104
124
  - LICENSE.txt
105
125
  - README.md
106
126
  - Rakefile
127
+ - lib/generators/hoardable/functions/hoardable_prevent_update_id.sql
128
+ - lib/generators/hoardable/functions/hoardable_source_set_id.sql
129
+ - lib/generators/hoardable/functions/hoardable_version_prevent_update.sql
107
130
  - lib/generators/hoardable/install_generator.rb
108
131
  - lib/generators/hoardable/migration_generator.rb
109
132
  - lib/generators/hoardable/templates/install.rb.erb
110
133
  - lib/generators/hoardable/templates/migration.rb.erb
134
+ - lib/generators/hoardable/triggers/prevent_update_hoardable_id.sql
135
+ - lib/generators/hoardable/triggers/set_hoardable_id.sql
136
+ - lib/generators/hoardable/triggers/versions_prevent_update.sql
111
137
  - lib/hoardable.rb
112
138
  - lib/hoardable/associations.rb
113
139
  - lib/hoardable/belongs_to.rb
@@ -141,14 +167,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
141
167
  requirements:
142
168
  - - ">="
143
169
  - !ruby/object:Gem::Version
144
- version: 2.6.0
170
+ version: 2.7.0
145
171
  required_rubygems_version: !ruby/object:Gem::Requirement
146
172
  requirements:
147
173
  - - ">="
148
174
  - !ruby/object:Gem::Version
149
175
  version: '0'
150
176
  requirements: []
151
- rubygems_version: 3.3.7
177
+ rubygems_version: 3.4.6
152
178
  signing_key:
153
179
  specification_version: 4
154
180
  summary: An ActiveRecord extension for versioning and soft-deletion of records in