hoardable 0.12.9 → 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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