hoardable 0.9.1 → 0.11.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: 5b85dfaf658e447049fcb09281d34dd25188be79f948eda294398f47b19b8244
4
- data.tar.gz: 67219612736259e2dbc542230cc1490a156a15bc679340176aca35b76aaf516c
3
+ metadata.gz: 2d9f9ea3a4559d3a4515357eabc0c7ee05526ee1308a4e6eef111f2d6207aef6
4
+ data.tar.gz: be0011cb1ea9d3fcdcd5d1a4f4951d7185371cc1d05c91dfd27ea163b59620b7
5
5
  SHA512:
6
- metadata.gz: cf3c5edfeac5526e7520dc2584caba8bb5399df43acf1ce6bd320b19f44981c154624b9b80c5b415aaeb840fc5d718f1999b4bbaf82eb3ff76149e09621ab0e5
7
- data.tar.gz: 52112fb9bc55bec2009656cce9571887130333b6460d5478c1a0c113895ccb977ffcc80172954911c6e70d2012f136bf68c64b17bc69ffd7deeaa40a56c97646
6
+ metadata.gz: 20ebc8d7ec2924e43e6bbd806e7aaf08350653b695dc4aef16de28359ceb95d8de16da4c19790e8b1212bc8d8d202caeb3614e05b326977bd470defe48e4b731
7
+ data.tar.gz: fc3fa92ae0ab25bee4122456b889f86eec75b0eee4efba742dced8737d7bc9ab3a99e63e63839f49089183900df8ed54a315a4561aad634c0e2ea3fa56aa8732
data/CHANGELOG.md CHANGED
@@ -2,57 +2,3 @@
2
2
 
3
3
  - Stability is coming.
4
4
 
5
- ## [0.9.0] - 2022-10-02
6
-
7
- - **Breaking Change** - `Hoardable.return_everything` was removed in favor of the newly added
8
- `Hoardable.at`.
9
-
10
- ## [0.8.0] - 2022-10-01
11
-
12
- - **Breaking Change** - Due to the performance benefit of using `insert` for database injection of
13
- versions, and a personal opinion that only an `after_versioned` hook might be needed, the
14
- `before_versioned` and `around_versioned` ActiveRecord hooks are removed.
15
-
16
- - **Breaking Change** - Another side effect of the performance benefit gained by using `insert` is
17
- that a source model will need to be reloaded before a call to `versions` on it can access the
18
- latest version after an `update` on the source record.
19
-
20
- - **Breaking Change** - Previously the inherited `_versions` tables did not have a unique index on
21
- the ID column, though it still pulled from the same sequence as the parent table. Prior to version
22
- 0.4.0 though, it was possible to have multiple trashed versions with the same ID. Adding unique
23
- indexes to version tables prior to version 0.4.0 could result in issues.
24
-
25
- ## [0.7.0] - 2022-09-29
26
-
27
- - **Breaking Change** - Continuing along with the change below, the `foreign_key` on the `_versions`
28
- tables is now changed to `hoardable_source_id` instead of the i18n model name dervied foreign key.
29
- The intent is to never leave room for conflict of foreign keys for existing relationships. This
30
- can be resolved by renaming the foreign key columns from their i18n model name derived column
31
- names to `hoardable_source_id`, i.e. `rename_column :post_versions, :post_id, :hoardable_source_id`.
32
-
33
- ## [0.6.0] - 2022-09-28
34
-
35
- - **Breaking Change** - Previously, a source model would `has_many :versions` with an inverse
36
- relationship based on the i18n interpreted name of the source model. Now it simply `has_many
37
- :versions, inverse_of :hoardable_source` to not potentially conflict with previously existing
38
- relationships.
39
-
40
- ## [0.5.0] - 2022-09-25
41
-
42
- - **Breaking Change** - Untrashing a version will now insert a version for the untrash event with
43
- it's own temporal timespan. This simplifies the ability to query versions temporarily for when
44
- they were trashed or not. This changes, but corrects, temporal query results using `.at`.
45
-
46
- - **Breaking Change** - Because of the above, a new operation enum value of "insert" was added. If
47
- you already have the `hoardable_operation` enum in your PostgreSQL schema, you can add it by
48
- executing the following SQL in a new migration: `ALTER TYPE hoardable_operation ADD VALUE
49
- 'insert';`.
50
-
51
- ## [0.4.0] - 2022-09-24
52
-
53
- - **Breaking Change** - Trashed versions now pull from the same postgres sequenced used by the
54
- source model’s table.
55
-
56
- ## [0.1.0] - 2022-07-23
57
-
58
- - Initial release
data/Gemfile CHANGED
@@ -5,6 +5,7 @@ source 'https://rubygems.org'
5
5
  gem 'benchmark-ips', '~> 2.10'
6
6
  gem 'debug', '~> 1.6'
7
7
  gem 'minitest', '~> 5.0'
8
+ gem 'rails', '>= 6.1'
8
9
  gem 'rake', '~> 13.0'
9
10
  gem 'rubocop', '~> 1.21'
10
11
  gem 'rubocop-minitest', '~> 0.20'
data/README.md CHANGED
@@ -29,14 +29,19 @@ Add this line to your application's Gemfile:
29
29
  gem 'hoardable'
30
30
  ```
31
31
 
32
- And then execute `bundle install`.
33
-
34
- If you would like to generate an initializer with the global [configuration](#configuration) options:
32
+ Run `bundle install`, and then run:
35
33
 
36
34
  ```
37
- rails g hoardable:initializer
35
+ bin/rails g hoardable:install
36
+ bin/rails db:migrate
38
37
  ```
39
38
 
39
+ This will generate a PostgreSQL function and an initiailzer.
40
+
41
+ _Note:_ It is recommended to set `config.active_record.schema_format = :sql` in `application.rb`, so
42
+ that the function and triggers in the migrations that prevent updates to the versions table get
43
+ captured in your schema.
44
+
40
45
  ### Model Installation
41
46
 
42
47
  You must include `Hoardable::Model` into an ActiveRecord model that you would like to hoard versions
@@ -66,10 +71,6 @@ explicitly, you can do so:
66
71
  bin/rails g hoardable:migration Post --foreign-key-type uuid
67
72
  ```
68
73
 
69
- _Note:_ If you are on Rails 6.1, you might want to set `config.active_record.schema_format = :sql`
70
- in `application.rb`, so that the enum type is captured in your schema dump. This is not required in
71
- Rails 7.
72
-
73
74
  _Note:_ Creating an inherited table does not copy over the indexes from the parent table. If you
74
75
  need to query versions often, you should add appropriate indexes to the `_versions` tables.
75
76
 
@@ -164,12 +165,11 @@ specifically with:
164
165
  ```ruby
165
166
  PostVersion.trashed
166
167
  Post.version_class.trashed # <- same thing as above
167
- PostVersion.trashed.first.trashed? # <- true
168
168
  ```
169
169
 
170
170
  _Note:_ A `Version` is not created upon initial parent model creation. To accurately track the
171
171
  beginning of the first temporal period, you will need to ensure the source model table has a
172
- `created_at` timestamp column.
172
+ `created_at` timestamp column. If this is missing, an error will be raised.
173
173
 
174
174
  ### Tracking Contextual Data
175
175
 
@@ -318,25 +318,25 @@ with `Hoardable` considerations.
318
318
 
319
319
  Sometimes you’ll have a record that belongs to a parent record that you’ll trash. Now the child
320
320
  record’s foreign key will point to the non-existent trashed version of the parent. If you would like
321
- to have `belongs_to` resolve to the trashed parent model in this case, you can use
322
- `belongs_to_trashable` in place of `belongs_to`:
321
+ to have `belongs_to` resolve to the trashed parent model in this case, you can give it the option of
322
+ `trashable: true`:
323
323
 
324
324
  ```ruby
325
325
  class Comment
326
326
  include Hoardable::Associations # <- This includes is not required if this model already includes `Hoardable::Model`
327
- belongs_to_trashable :post, -> { where(status: 'published') }, class_name: 'Article' # <- Accepts normal `belongs_to` arguments
327
+ belongs_to :post, trashable: true
328
328
  end
329
329
  ```
330
330
 
331
331
  Sometimes you'll have a Hoardable record that `has_many` other Hoardable records and you will want
332
332
  to know the state of both the parent record and the children at a cetain point in time. You
333
- accomplish this by establishing a `has_many_hoardable` relationship and using the `Hoardable.at`
334
- method:
333
+ accomplish this by adding `hoardable: true` to the `has_many` relationship and using the
334
+ `Hoardable.at` method:
335
335
 
336
336
  ```ruby
337
337
  class Post
338
338
  include Hoardable::Model
339
- has_many_hoardable :comments
339
+ has_many :comments, hoardable: true
340
340
  end
341
341
 
342
342
  def Comment
@@ -352,7 +352,7 @@ post.update!(title: 'New Title')
352
352
  post_id = post.id # 1
353
353
 
354
354
  Hoardable.at(datetime) do
355
- post = Post.hoardable.find(post_id)
355
+ post = Post.find(post_id)
356
356
  post.title # => 'Title'
357
357
  post.comments.size # => 2
358
358
  post.id # => 2
@@ -370,11 +370,9 @@ version, even though it is masquerading as a `Post`.
370
370
 
371
371
  If you are ever unsure if a Hoardable record is a "source" or a "version", you can be sure by
372
372
  calling `version?` on it. If you want to get the true original source record ID, you can call
373
- `hoardable_source_id`. Finally, if you prepend `.hoardable` to a `.find` call on the source model
374
- class, you can always find the relevant source or temporal version record using just the original
375
- source record’s id.
373
+ `hoardable_source_id`.
376
374
 
377
- Sometimes you’ll trash something that `has_many_hoardable :children, dependent: :destroy` and want
375
+ Sometimes you’ll trash something that `has_many :children, dependent: :destroy` and want
378
376
  to untrash everything in a similar dependent manner. Whenever a hoardable version is created in a
379
377
  database transaction, it will create or re-use a unique event UUID for that transaction and tag all
380
378
  versions created with it. That way, when you `untrash!` a record, you can find and `untrash!`
@@ -383,7 +381,7 @@ records that were trashed with it:
383
381
  ```ruby
384
382
  class Post < ActiveRecord::Base
385
383
  include Hoardable::Model
386
- has_many_hoardable :comments, dependent: :destroy # `Comment` also includes `Hoardable::Model`
384
+ has_many :comments, hoardable: true, dependent: :destroy # `Comment` also includes `Hoardable::Model`
387
385
 
388
386
  after_untrashed do
389
387
  Comment
@@ -395,9 +393,42 @@ class Post < ActiveRecord::Base
395
393
  end
396
394
  ```
397
395
 
396
+ ## ActionText
397
+
398
+ Hoardable provides support for ActiveRecord models with `has_rich_text`. First, you must create a
399
+ temporal table for `ActionText::RichText`:
400
+
401
+ ```
402
+ bin/rails g hoardable:migration ActionText::RichText
403
+ bin/rails db:migrate
404
+ ```
405
+
406
+ Then in your model, include `Hoardable::Model` and provide the `hoardable: true` keyword to
407
+ `has_rich_text`:
408
+
409
+ ``` ruby
410
+ class Post < ActiveRecord::Base
411
+ include Hoardable::Model # or `Hoardable::Associations` if you don't need `PostVersion`
412
+ has_rich_text :content, hoardable: true
413
+ end
414
+ ```
415
+
416
+ Now the `rich_text_content` relationship will be managed as a Hoardable `has_one` relationship:
417
+
418
+ ``` ruby
419
+ post = Post.create!(content: '<div>Hello World</div>')
420
+ datetime = DateTime.current
421
+ post.update!(content: '<div>Goodbye Cruel World</div>')
422
+ post.content.versions.size # => 1
423
+ assert_equal post.content.to_plain_text, 'Goodbye Cruel World'
424
+ Hoardable.at(datetime) do
425
+ assert_equal post.content.to_plain_text, 'Hello World'
426
+ end
427
+ ```
428
+
398
429
  ## Gem Comparison
399
430
 
400
- ### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
431
+ #### [`paper_trail`](https://github.com/paper-trail-gem/paper_trail)
401
432
 
402
433
  `paper_trail` is maybe the most popular and fully featured gem in this space. It works for other
403
434
  database types than PostgeSQL and (by default) stores all versions of all versioned models in a
@@ -410,7 +441,7 @@ same database columns as their parents, which are more efficient for querying as
410
441
  for truncating and dropping. The concept of a `temporal` time-frame does not exist for a single
411
442
  version since there is only a `created_at` timestamp.
412
443
 
413
- ### [`audited`](https://github.com/collectiveidea/audited)
444
+ #### [`audited`](https://github.com/collectiveidea/audited)
414
445
 
415
446
  `audited` works in a similar manner as `paper_trail`. It stores all versions for all model types in
416
447
  a single table, you must opt into using `jsonb` as the column type to store "changes", in case you
@@ -418,7 +449,7 @@ want to query them, and there is no concept of a `temporal` time-frame for a sin
418
449
  makes opinionated decisions about contextual data requirements and stores them as top level data
419
450
  types on the `audited` table.
420
451
 
421
- ### [`discard`](https://github.com/jhawthorn/discard)
452
+ #### [`discard`](https://github.com/jhawthorn/discard)
422
453
 
423
454
  `discard` only covers soft-deletion. The act of "soft deleting" a record is only captured through
424
455
  the time-stamping of a `discarded_at` column on the records table; there is no other capturing of
@@ -427,7 +458,7 @@ record is restored, the previous "discarded" awareness is lost. Since "discarded
427
458
  the same table as "undiscarded" records, you must explicitly omit the discarded records from queries
428
459
  across your app to keep them from leaking in.
429
460
 
430
- ### [`paranoia`](https://github.com/rubysherpas/paranoia)
461
+ #### [`paranoia`](https://github.com/rubysherpas/paranoia)
431
462
 
432
463
  `paranoia` also only covers soft-deletion. In their README, they recommend using `discard` instead
433
464
  of `paranoia` because of the fact they override ActiveRecord’s `delete` and `destroy` methods.
@@ -435,6 +466,13 @@ of `paranoia` because of the fact they override ActiveRecord’s `delete` and `d
435
466
  `paranoia` works similarly to `discard` in that it keeps deleted records in the same table and tags
436
467
  them with a `deleted_at` timestamp. No other information about the soft-deletion event is stored.
437
468
 
469
+ #### [`logidze`](https://github.com/palkan/logidze)
470
+
471
+ `logidze` is an interesting versioning alternative that leverages the power of PostgreSQL triggers.
472
+ Instead of storing the previous versions or changes in a separate table, it stores them in a
473
+ proprietary JSON format directly on the database row of the record itself. If does not support soft
474
+ deletion.
475
+
438
476
  ## Contributing
439
477
 
440
478
  This gem still quite new and very open to feedback.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Hoardable
6
+ # Generates an initializer file for {Hoardable} configuration and a migration with a PostgreSQL
7
+ # function.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+ include Rails::Generators::Migration
11
+
12
+ def create_initializer_file
13
+ create_file(
14
+ 'config/initializers/hoardable.rb',
15
+ <<~TEXT
16
+ # Hoardable configuration defaults are below. Learn more at https://github.com/waymondo/hoardable#configuration
17
+ #
18
+ # Hoardable.enabled = true
19
+ # Hoardable.version_updates = true
20
+ # Hoardable.save_trash = true
21
+ TEXT
22
+ )
23
+ end
24
+
25
+ def create_migration_file
26
+ migration_template 'functions.rb.erb', 'db/migrate/install_hoardable.rb'
27
+ end
28
+
29
+ def self.next_migration_number(dir)
30
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InstallHoardable < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def up
5
+ execute(
6
+ <<~SQL
7
+ CREATE OR REPLACE FUNCTION hoardable_version_prevent_update() RETURNS trigger
8
+ LANGUAGE plpgsql AS
9
+ $$BEGIN
10
+ RAISE EXCEPTION 'updating a version is not allowed';
11
+ RETURN NEW;
12
+ END;$$;
13
+ SQL
14
+ )
15
+ end
16
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
3
+ class Create<%= class_name.singularize.delete(':') %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  create_enum :hoardable_operation, %w[update delete insert]
6
6
  create_table :<%= singularized_table_name %>_versions, id: false, options: 'INHERITS (<%= table_name %>)' do |t|
@@ -10,20 +10,25 @@ 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
+ reversible do |dir|
14
+ dir.up do
15
+ execute(
16
+ <<~SQL
17
+ CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
18
+ BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
19
+ EXECUTE PROCEDURE hoardable_version_prevent_update();
20
+ SQL
21
+ )
22
+ end
23
+ dir.down do
24
+ execute(
25
+ <<~SQL
26
+ DROP TRIGGER <%= singularized_table_name %>_versions_prevent_update
27
+ ON <%= singularized_table_name %>_versions;
28
+ SQL
29
+ )
30
+ end
31
+ end
27
32
  add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
28
33
  add_index(
29
34
  :<%= singularized_table_name %>_versions,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Create<%= class_name.singularize %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
3
+ class Create<%= class_name.singularize.delete(':') %>Versions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  reversible do |dir|
6
6
  dir.up do
@@ -25,20 +25,25 @@ 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
+ reversible do |dir|
29
+ dir.up do
30
+ execute(
31
+ <<~SQL
32
+ CREATE TRIGGER <%= singularized_table_name %>_versions_prevent_update
33
+ BEFORE UPDATE ON <%= singularized_table_name %>_versions FOR EACH ROW
34
+ EXECUTE PROCEDURE hoardable_version_prevent_update();
35
+ SQL
36
+ )
37
+ end
38
+ dir.down do
39
+ execute(
40
+ <<~SQL
41
+ DROP TRIGGER <%= singularized_table_name %>_versions_prevent_update
42
+ ON <%= singularized_table_name %>_versions;
43
+ SQL
44
+ )
45
+ end
46
+ end
42
47
  add_index(:<%= singularized_table_name %>_versions, :id, unique: true)
43
48
  add_index(
44
49
  :<%= singularized_table_name %>_versions,
@@ -7,54 +7,12 @@ module Hoardable
7
7
  module Associations
8
8
  extend ActiveSupport::Concern
9
9
 
10
- # An +ActiveRecord+ extension that allows looking up {VersionModel}s by +hoardable_source_id+ as
11
- # if they were {SourceModel}s.
12
- module HasManyScope
13
- def scope
14
- @scope ||= hoardable_scope
15
- end
16
-
17
- private
18
-
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 :HasManyScope
29
-
30
- class_methods do
31
- # A wrapper for +ActiveRecord+’s +belongs_to+ that allows for falling back to the most recent
32
- # trashed +version+, in the case that the related source has been trashed.
33
- def belongs_to_trashable(name, scope = nil, **options)
34
- belongs_to(name, scope, **options)
35
-
36
- trashable_relationship_name = "trashable_#{name}"
37
-
38
- define_method(trashable_relationship_name) do
39
- source_reflection = self.class.reflections[name.to_s]
40
- version_class = source_reflection.version_class
41
- version_class.trashed.only_most_recent.find_by(
42
- hoardable_source_id: source_reflection.foreign_key
43
- )
44
- end
45
-
46
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
47
- def #{name}
48
- super || #{trashable_relationship_name}
49
- end
50
- RUBY
51
- end
52
-
53
- # A wrapper for +ActiveRecord+’s +has_many+ that allows for finding temporal versions of a
54
- # record cast as instances of the {SourceModel}, when doing a {Hoardable#at} query.
55
- def has_many_hoardable(name, scope = nil, **options)
56
- has_many(name, scope, **options) { include HasManyScope }
57
- end
10
+ included do
11
+ include HasMany
12
+ include HasOne
13
+ include BelongsTo
14
+ include HasRichText
15
+ include HasOneAttached
58
16
  end
59
17
  end
60
18
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # A {Hoardable} subclass of {ActiveStorage::Attachment}
5
+ class Attachment < ActiveStorage::Attachment
6
+ include Model
7
+
8
+ class CreateOne < ActiveStorage::Attached::Changes::CreateOne
9
+ private
10
+
11
+ def build_attachment
12
+ Attachment.new(record: record, name: name, blob: blob)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # Provides awareness of trashed source records to +belongs_to+ relationships.
5
+ module BelongsTo
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def belongs_to(*args)
10
+ options = args.extract_options!
11
+ trashable = options.delete(:trashable)
12
+ super(*args, **options)
13
+ return unless trashable
14
+
15
+ hoardable_override_belongs_to(args.first)
16
+ end
17
+
18
+ private
19
+
20
+ def hoardable_override_belongs_to(name)
21
+ define_method("trashed_#{name}") do
22
+ source_reflection = reflections[name.to_s]
23
+ source_reflection.version_class.trashed.only_most_recent.find_by(
24
+ hoardable_source_id: source_reflection.foreign_key
25
+ )
26
+ end
27
+
28
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
29
+ def #{name}
30
+ super || trashed_#{name}
31
+ end
32
+ RUBY
33
+ end
34
+ end
35
+ end
36
+ private_constant :BelongsTo
37
+ end
@@ -73,25 +73,9 @@ module Hoardable
73
73
  end
74
74
 
75
75
  def hoardable_source_epoch
76
- if source_record.class.column_names.include?('created_at')
77
- source_record.created_at
78
- else
79
- maybe_warn_about_missing_created_at_column
80
- Time.at(0).utc
81
- end
82
- end
76
+ return source_record.created_at if source_record.class.column_names.include?('created_at')
83
77
 
84
- def maybe_warn_about_missing_created_at_column
85
- return unless source_record.class.hoardable_config[:warn_on_missing_created_at_column]
86
-
87
- source_table_name = source_record.class.table_name
88
- Hoardable.logger.info(
89
- <<~LOG
90
- '#{source_table_name}' does not have a 'created_at' column, so the first version’s temporal period
91
- will begin at the unix epoch instead. Add a 'created_at' column to '#{source_table_name}'
92
- or set 'Hoardable.warn_on_missing_created_at_column = false' to disable this message.
93
- LOG
94
- )
78
+ raise CreatedAtColumnMissingError, source_record.class.table_name
95
79
  end
96
80
  end
97
81
  private_constant :DatabaseClient
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # A {Hoardable} subclass of {ActionText::EncryptedRichText}.
5
+ class EncryptedRichText < ActionText::EncryptedRichText
6
+ include Model
7
+ end
8
+ 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 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
@@ -34,6 +34,9 @@ module Hoardable
34
34
  end.freeze
35
35
  private_constant :HOARDABLE_VERSION_UPDATES
36
36
 
37
+ SUPPORTS_ENCRYPTED_ACTION_TEXT = ActiveRecord.version >= ::Gem::Version.new('7.0')
38
+ private_constant :SUPPORTS_ENCRYPTED_ACTION_TEXT
39
+
37
40
  @context = {}
38
41
  @config = CONFIG_KEYS.to_h do |key|
39
42
  [key, true]
@@ -91,4 +94,22 @@ module Hoardable
91
94
  @logger ||= ActiveSupport::TaggedLogging.new(Logger.new($stdout))
92
95
  end
93
96
  end
97
+
98
+ # A +Rails+ engine for providing support for +ActionText+
99
+ class Engine < ::Rails::Engine
100
+ isolate_namespace Hoardable
101
+
102
+ initializer 'hoardable.action_text' do
103
+ ActiveSupport.on_load(:action_text_rich_text) do
104
+ require_relative 'rich_text'
105
+ require_relative 'encrypted_rich_text' if SUPPORTS_ENCRYPTED_ACTION_TEXT
106
+ end
107
+ end
108
+
109
+ initializer 'hoardable.active_storage' do
110
+ ActiveSupport.on_load(:active_storage_attachment) do
111
+ require_relative 'attachment'
112
+ end
113
+ end
114
+ end
94
115
  end
@@ -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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # Provides temporal version awareness to +has_many+ relationships.
5
+ module HasMany
6
+ extend ActiveSupport::Concern
7
+
8
+ # An +ActiveRecord+ extension that allows looking up {VersionModel}s by +hoardable_source_id+ as
9
+ # if they were {SourceModel}s when using {Hoardable#at}.
10
+ module HasManyExtension
11
+ def scope
12
+ @scope ||= hoardable_scope
13
+ end
14
+
15
+ private
16
+
17
+ def hoardable_scope
18
+ if Hoardable.instance_variable_get('@at') &&
19
+ (hoardable_source_id = @association.owner.hoardable_source_id)
20
+ @association.scope.rewhere(@association.reflection.foreign_key => hoardable_source_id)
21
+ else
22
+ @association.scope
23
+ end
24
+ end
25
+ end
26
+ private_constant :HasManyExtension
27
+
28
+ class_methods do
29
+ def has_many(*args, &block)
30
+ options = args.extract_options!
31
+ options[:extend] = Array(options[:extend]).push(HasManyExtension) if options.delete(:hoardable)
32
+ super(*args, **options, &block)
33
+
34
+ # This hack is needed to force Rails to not use any existing method cache so that the
35
+ # {HasManyExtension} scope is always used.
36
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
37
+ def #{args.first}
38
+ super.extending
39
+ end
40
+ RUBY
41
+ end
42
+ end
43
+ end
44
+ private_constant :HasMany
45
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # Provides temporal version awareness to +has_one+ relationships.
5
+ module HasOne
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def has_one(*args)
10
+ options = args.extract_options!
11
+ hoardable = options.delete(:hoardable)
12
+ association = super(*args, **options)
13
+ name = args.first
14
+ return unless hoardable || association[name.to_s].options[:class_name].match?(/RichText$/)
15
+
16
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
17
+ def #{name}
18
+ return super unless (at = Hoardable.instance_variable_get('@at'))
19
+
20
+ super&.version_at(at) ||
21
+ _reflections['profile'].klass.where(_reflections['profile'].foreign_key => id).first
22
+ end
23
+ RUBY
24
+ end
25
+ end
26
+ end
27
+ private_constant :HasOne
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # Provides temporal version awareness for +ActionText+.
5
+ module HasOneAttached
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def has_one_attached(name, **options, &block)
10
+ hoardable = options.delete(:hoardable)
11
+ super(name, **options, &block)
12
+ return unless hoardable
13
+
14
+ 'ActiveStorage::Attachment'.constantize
15
+ reflection_options = reflections["#{name}_attachment"].options
16
+ reflection_options[:class_name] = reflection_options[:class_name].sub(/ActiveStorage/, 'Hoardable')
17
+
18
+ generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
19
+ # frozen_string_literal: true
20
+ def #{name}=(attachable)
21
+ attachment_changes["#{name}"] =
22
+ if attachable.nil?
23
+ ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
24
+ else
25
+ Hoardable::Attachment::CreateOne.new("#{name}", self, attachable)
26
+ end
27
+ end
28
+ CODE
29
+ end
30
+ end
31
+ end
32
+ private_constant :HasOneAttached
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # Provides temporal version awareness for +ActionText+.
5
+ module HasRichText
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def has_rich_text(name, encrypted: false, hoardable: false)
10
+ if SUPPORTS_ENCRYPTED_ACTION_TEXT
11
+ super(name, encrypted: encrypted)
12
+ else
13
+ super(name)
14
+ end
15
+ return unless hoardable
16
+
17
+ reflection_options = reflections["rich_text_#{name}"].options
18
+ reflection_options[:class_name] = reflection_options[:class_name].sub(/ActionText/, 'Hoardable')
19
+ end
20
+ end
21
+ end
22
+ private_constant :HasRichText
23
+ end
@@ -53,11 +53,17 @@ module Hoardable
53
53
  TracePoint.new(:end) do |trace|
54
54
  next unless self == trace.self
55
55
 
56
- version_class_name = "#{name}#{VERSION_CLASS_SUFFIX}"
57
- unless Object.const_defined?(version_class_name)
58
- Object.const_set(version_class_name, Class.new(self) { include VersionModel })
56
+ full_version_class_name = "#{name}#{VERSION_CLASS_SUFFIX}"
57
+ if (namespace_match = full_version_class_name.match(/(.*)::(.*)/))
58
+ object_namespace = namespace_match[1].constantize
59
+ version_class_name = namespace_match[2]
60
+ else
61
+ object_namespace = Object
62
+ version_class_name = full_version_class_name
63
+ end
64
+ unless Object.const_defined?(full_version_class_name)
65
+ object_namespace.const_set(version_class_name, Class.new(self) { include VersionModel })
59
66
  end
60
-
61
67
  include SourceModel
62
68
 
63
69
  trace.disable
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoardable
4
+ # A {Hoardable} subclass of {ActionText::RichText}
5
+ class RichText < ActionText::RichText
6
+ include Model
7
+ end
8
+ end
@@ -60,9 +60,13 @@ module Hoardable
60
60
  # Returns instances of the source model and versions that were valid at the supplied
61
61
  # +datetime+ or +time+, all cast as instances of the source model.
62
62
  scope :at, lambda { |datetime|
63
+ raise(CreatedAtColumnMissingError, @klass.table_name) unless @klass.column_names.include?('created_at')
64
+
63
65
  include_versions.where(id: version_class.at(datetime).select('id')).or(
64
- where.not(id: version_class.select(:hoardable_source_id).where(DURING_QUERY, datetime))
65
- )
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
66
70
  }
67
71
  end
68
72
 
@@ -16,16 +16,6 @@ module Hoardable
16
16
  # @return [String] The database operation that created the +version+ - either +update+ or +delete+.
17
17
  delegate :hoardable_event_uuid, :hoardable_operation, to: :hoardable_version, allow_nil: true
18
18
 
19
- # A module for overriding +ActiveRecord#find_one+’ in the case you are doing a temporal query
20
- # and the current {SourceModel} record may in fact be a {VersionModel} record.
21
- module FinderMethods
22
- def find_one(id)
23
- conditions = { primary_key => [id, *version_class.where(hoardable_source_id: id).select(primary_key).ids] }
24
- find_by(conditions) || where(conditions).raise_record_not_found_exception!
25
- end
26
- end
27
- private_constant :FinderMethods
28
-
29
19
  class_methods do
30
20
  # The dynamically generated +Version+ class for this model.
31
21
  def version_class
@@ -42,6 +32,8 @@ module Hoardable
42
32
  include Scopes
43
33
 
44
34
  around_update(if: [HOARDABLE_CALLBACKS_ENABLED, HOARDABLE_VERSION_UPDATES]) do |_, block|
35
+ next if self.is_a?(Hoardable::Attachment)
36
+
45
37
  hoardable_client.insert_hoardable_version('update', &block)
46
38
  end
47
39
 
@@ -50,7 +42,7 @@ module Hoardable
50
42
  end
51
43
 
52
44
  before_destroy(if: HOARDABLE_CALLBACKS_ENABLED, unless: HOARDABLE_SAVE_TRASH) do
53
- versions.delete_all(:delete_all)
45
+ versions.delete_all
54
46
  end
55
47
 
56
48
  after_commit { hoardable_client.unset_hoardable_version_and_event_uuid }
@@ -81,14 +73,21 @@ module Hoardable
81
73
  !!hoardable_client.hoardable_version_source_id
82
74
  end
83
75
 
84
- # Returns the +version+ at the supplied +datetime+ or +time+. It will return +self+ if there is
85
- # none. This will raise an error if you try to find a version in the future.
76
+ # Returns the +version+ at the supplied +datetime+ or +time+, or +self+ if there is none.
86
77
  #
87
78
  # @param datetime [DateTime, Time]
88
79
  def at(datetime)
80
+ version_at(datetime) || (self if created_at < datetime)
81
+ end
82
+
83
+ # Returns the +version+ at the supplied +datetime+ or +time+. This will raise an error if you
84
+ # try to find a version in the future.
85
+ #
86
+ # @param datetime [DateTime, Time]
87
+ def version_at(datetime)
89
88
  raise(Error, 'Future state cannot be known') if datetime.future?
90
89
 
91
- versions.at(datetime).first || self
90
+ versions.at(datetime).limit(1).first
92
91
  end
93
92
 
94
93
  # If a version is found at the supplied datetime, it will +revert!+ to it and return it. This
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hoardable
4
- VERSION = '0.9.1'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/hoardable.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_record'
3
4
  require_relative 'hoardable/version'
4
- require_relative 'hoardable/hoardable'
5
+ require_relative 'hoardable/engine'
6
+ require_relative 'hoardable/finder_methods'
5
7
  require_relative 'hoardable/scopes'
6
8
  require_relative 'hoardable/error'
7
9
  require_relative 'hoardable/database_client'
@@ -9,5 +11,10 @@ require_relative 'hoardable/source_model'
9
11
  require_relative 'hoardable/version_model'
10
12
  require_relative 'hoardable/model'
11
13
  require_relative 'hoardable/associations'
14
+ require_relative 'hoardable/has_many'
15
+ require_relative 'hoardable/belongs_to'
16
+ require_relative 'hoardable/has_one'
17
+ require_relative 'hoardable/has_rich_text'
18
+ require_relative 'hoardable/has_one_attached'
12
19
  require_relative 'generators/hoardable/migration_generator'
13
- require_relative 'generators/hoardable/initializer_generator'
20
+ require_relative 'generators/hoardable/install_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, :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
@@ -17,8 +17,17 @@ module Hoardable
17
17
  def self.at: (untyped datetime) -> untyped
18
18
  def self.logger: -> untyped
19
19
 
20
+ module FinderMethods
21
+ def find_one: (untyped id) -> untyped
22
+ def find_some: (untyped ids) -> untyped
23
+
24
+ private
25
+ def hoardable_source_ids: ([untyped] ids) -> Array[untyped]
26
+ end
27
+
20
28
  module Scopes
21
29
  TABLEOID_AREL_CONDITIONS: Proc
30
+ self.@klass: bot
22
31
 
23
32
  private
24
33
  def tableoid: -> untyped
@@ -30,6 +39,10 @@ module Hoardable
30
39
  class Error < StandardError
31
40
  end
32
41
 
42
+ class CreatedAtColumnMissingError < Error
43
+ def initialize: (untyped source_table_name) -> void
44
+ end
45
+
33
46
  class DatabaseClient
34
47
  @hoardable_version_source_id: untyped
35
48
 
@@ -45,8 +58,7 @@ module Hoardable
45
58
  def assign_hoardable_context: (:event_uuid | :meta | :note | :whodunit key) -> nil
46
59
  def unset_hoardable_version_and_event_uuid: -> nil
47
60
  def previous_temporal_tsrange_end: -> untyped
48
- def hoardable_source_epoch: -> Time
49
- def maybe_warn_about_missing_created_at_column: -> nil
61
+ def hoardable_source_epoch: -> untyped
50
62
  end
51
63
 
52
64
  module SourceModel
@@ -56,7 +68,8 @@ module Hoardable
56
68
  attr_reader hoardable_version: untyped
57
69
  def trashed?: -> untyped
58
70
  def version?: -> untyped
59
- def at: (untyped datetime) -> SourceModel
71
+ def at: (untyped datetime) -> SourceModel?
72
+ def version_at: (untyped datetime) -> untyped
60
73
  def revert_to!: (untyped datetime) -> SourceModel?
61
74
  def hoardable_source_id: -> untyped
62
75
 
@@ -66,10 +79,6 @@ module Hoardable
66
79
  public
67
80
  def version_class: -> untyped
68
81
  def hoardable: -> untyped
69
-
70
- module FinderMethods
71
- def find_one: (untyped id) -> untyped
72
- end
73
82
  end
74
83
 
75
84
  module VersionModel
@@ -99,10 +108,16 @@ module Hoardable
99
108
  end
100
109
 
101
110
  module Associations
102
- def belongs_to_trashable: (untyped name, ?nil scope, **untyped) -> untyped
103
- def has_many_hoardable: (untyped name, ?nil scope, **untyped) -> untyped
111
+ @hoardable_association_overrider: Overrider
112
+
113
+ def belongs_to: (*untyped args) -> nil
114
+ def has_one: (*untyped args) -> nil
115
+ def has_many: (*untyped args) -> untyped
116
+
117
+ private
118
+ def hoardable_association_overrider: -> Overrider
104
119
 
105
- module HasManyScope
120
+ module HasManyExtension
106
121
  @scope: untyped
107
122
  @association: bot
108
123
 
@@ -111,6 +126,14 @@ module Hoardable
111
126
  private
112
127
  def hoardable_scope: -> untyped
113
128
  end
129
+
130
+ class Overrider
131
+ attr_reader klass: Associations
132
+ def initialize: (Associations klass) -> void
133
+ def override_belongs_to: (untyped name) -> untyped
134
+ def override_has_one: (untyped name) -> untyped
135
+ def override_has_many: (untyped name) -> untyped
136
+ end
114
137
  end
115
138
 
116
139
  class MigrationGenerator
@@ -122,7 +145,9 @@ module Hoardable
122
145
  def singularized_table_name: -> untyped
123
146
  end
124
147
 
125
- class InitializerGenerator
148
+ class InstallGenerator
126
149
  def create_initializer_file: -> untyped
150
+ def create_migration_file: -> untyped
151
+ def self.next_migration_number: (untyped dir) -> untyped
127
152
  end
128
153
  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.9.1
4
+ version: 0.11.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-03 00:00:00.000000000 Z
11
+ date: 2022-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -104,16 +104,26 @@ files:
104
104
  - LICENSE.txt
105
105
  - README.md
106
106
  - Rakefile
107
- - lib/generators/hoardable/initializer_generator.rb
107
+ - lib/generators/hoardable/install_generator.rb
108
108
  - lib/generators/hoardable/migration_generator.rb
109
+ - lib/generators/hoardable/templates/functions.rb.erb
109
110
  - lib/generators/hoardable/templates/migration.rb.erb
110
111
  - lib/generators/hoardable/templates/migration_6.rb.erb
111
112
  - lib/hoardable.rb
112
113
  - lib/hoardable/associations.rb
114
+ - lib/hoardable/attachment.rb
115
+ - lib/hoardable/belongs_to.rb
113
116
  - lib/hoardable/database_client.rb
117
+ - lib/hoardable/encrypted_rich_text.rb
118
+ - lib/hoardable/engine.rb
114
119
  - lib/hoardable/error.rb
115
- - lib/hoardable/hoardable.rb
120
+ - lib/hoardable/finder_methods.rb
121
+ - lib/hoardable/has_many.rb
122
+ - lib/hoardable/has_one.rb
123
+ - lib/hoardable/has_one_attached.rb
124
+ - lib/hoardable/has_rich_text.rb
116
125
  - lib/hoardable/model.rb
126
+ - lib/hoardable/rich_text.rb
117
127
  - lib/hoardable/scopes.rb
118
128
  - lib/hoardable/source_model.rb
119
129
  - lib/hoardable/version.rb
@@ -1,21 +0,0 @@
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