historiographer 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +115 -39
- data/lib/historiographer/configuration.rb +36 -0
- data/lib/historiographer/history.rb +9 -2
- data/lib/historiographer/history_migration.rb +9 -6
- data/lib/historiographer/relation.rb +1 -1
- data/lib/historiographer/version.rb +3 -0
- data/lib/historiographer.rb +176 -11
- metadata +3 -30
- data/.document +0 -5
- data/.rspec +0 -1
- data/.ruby-version +0 -1
- data/.standalone_migrations +0 -6
- data/Gemfile +0 -34
- data/Gemfile.lock +0 -289
- data/Guardfile +0 -70
- data/Rakefile +0 -54
- data/VERSION +0 -1
- data/historiographer.gemspec +0 -106
- data/init.rb +0 -18
- data/spec/db/database.yml +0 -25
- data/spec/db/migrate/20161121212228_create_posts.rb +0 -19
- data/spec/db/migrate/20161121212229_create_post_histories.rb +0 -10
- data/spec/db/migrate/20161121212230_create_authors.rb +0 -13
- data/spec/db/migrate/20161121212231_create_author_histories.rb +0 -10
- data/spec/db/migrate/20161121212232_create_users.rb +0 -9
- data/spec/db/migrate/20171011194624_create_safe_posts.rb +0 -19
- data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +0 -9
- data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +0 -10
- data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +0 -11
- data/spec/db/migrate/20191024203106_create_thing_without_history.rb +0 -7
- data/spec/db/migrate/20221018204220_create_silent_posts.rb +0 -21
- data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +0 -9
- data/spec/db/schema.rb +0 -186
- data/spec/examples.txt +0 -32
- data/spec/factories/post.rb +0 -7
- data/spec/historiographer_spec.rb +0 -588
- data/spec/spec_helper.rb +0 -52
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 740453114d73fc300d55d887a57f8bbf447042bb5d65da886125ece34982bf33
         | 
| 4 | 
            +
              data.tar.gz: 201cfd99a05408c4f4387d4c7244d87c985ce82c7aaab24db8114c7760831f31
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 93d61f00c711ef9ef058e1e3eef9444755ea24756d5bb139cd2f657396061f80d9f405ed6eef4d86164fad8b10abd7e9103d0776556a2d1d344b379dab9d6076
         | 
| 7 | 
            +
              data.tar.gz: acfe88c0b2fad42d24484b62fcd3cae84149eca55d0aea1d1f6771a5c6e785508679a5430f533d427c6aa460a8a5f355023f50ad42614282ae5d7b88591aaf19
         | 
    
        data/README.md
    CHANGED
    
    | @@ -8,21 +8,11 @@ Historiographer fixes this problem in a better way than existing auditing gems. | |
| 8 8 |  | 
| 9 9 | 
             
            The Audited gem has some serious flaws.
         | 
| 10 10 |  | 
| 11 | 
            -
             | 
| 11 | 
            +
            1. The `versions` table quickly grows too large to query
         | 
| 12 12 |  | 
| 13 | 
            -
             | 
| 13 | 
            +
            2. It doesn't provide the indexes you need from your primary tables
         | 
| 14 14 |  | 
| 15 | 
            -
             | 
| 16 | 
            -
             | 
| 17 | 
            -
            Why does this happen?
         | 
| 18 | 
            -
             | 
| 19 | 
            -
            First, `audited` only tracks a record of what changed, so there's no way to "go back in time" and see what the data looked like back when a problem occurred without replaying every single audit.
         | 
| 20 | 
            -
             | 
| 21 | 
            -
            Second, it tracks changes as JSON. While some data stores have JSON querying semantics, not all do, making it very hard to ask complex questions of your historical data -- that's the whole reason you're keeping it around.
         | 
| 22 | 
            -
             | 
| 23 | 
            -
            Third, it doesn't maintain indexes on your data. If you maintain an index on the primary table, wouldn't you also want to look up historical records using the same columns? Historical data is MUCH larger than "latest snapshot" data, so, duh, of course you do.
         | 
| 24 | 
            -
             | 
| 25 | 
            -
            Finally, Audited creates just one table for all audits. Historical data is big. It's not unusual for an audited gem table to get into the many millions of rows, and need to be constantly partitioned to maintain any kind of efficiency.
         | 
| 15 | 
            +
            3. It doesn't provdie out-of-the-box snapshots
         | 
| 26 16 |  | 
| 27 17 | 
             
            ## How does Historiographer solve these problems?
         | 
| 28 18 |  | 
| @@ -30,44 +20,116 @@ Historiographer introduces the concept of _history tables:_ append-only tables t | |
| 30 20 |  | 
| 31 21 | 
             
            If you have a `posts` table:
         | 
| 32 22 |  | 
| 33 | 
            -
            | id | 
| 34 | 
            -
            |  | 
| 35 | 
            -
            | 1 | 
| 36 | 
            -
            | 2 | 
| 23 | 
            +
            | id  | title          |
         | 
| 24 | 
            +
            | :-- | :------------- |
         | 
| 25 | 
            +
            | 1   | My Great Post  |
         | 
| 26 | 
            +
            | 2   | My Second Post |
         | 
| 37 27 |  | 
| 38 28 | 
             
            You'll also have a `post_histories_table`:
         | 
| 39 29 |  | 
| 40 | 
            -
            | id | 
| 41 | 
            -
            |  | 
| 42 | 
            -
            | 1 | 
| 43 | 
            -
            | 2 | 
| 30 | 
            +
            | id  | post_id | title          | history_started_at | history_ended_at | history_user_id |
         | 
| 31 | 
            +
            | :-- | :------ | :------------- | :----------------- | :--------------- | :-------------- |
         | 
| 32 | 
            +
            | 1   | 1       | My Great Post  | '2019-11-08'       | NULL             | 1               |
         | 
| 33 | 
            +
            | 2   | 2       | My Second Post | '2019-11-08'       | NULL             | 1               |
         | 
| 44 34 |  | 
| 45 35 | 
             
            If you change the title of the 1st post:
         | 
| 46 36 |  | 
| 47 | 
            -
             | 
| 37 | 
            +
            `Post.find(1).update(title: "Title With Better SEO", history_user_id: current_user.id)`
         | 
| 48 38 |  | 
| 49 39 | 
             
            You'll expect your `posts` table to be updated directly:
         | 
| 50 40 |  | 
| 51 | 
            -
            | id | 
| 52 | 
            -
            |  | 
| 53 | 
            -
            | 1 | 
| 54 | 
            -
            | 2 | 
| 41 | 
            +
            | id  | title                 |
         | 
| 42 | 
            +
            | :-- | :-------------------- |
         | 
| 43 | 
            +
            | 1   | Title With Better SEO |
         | 
| 44 | 
            +
            | 2   | My Second Post        |
         | 
| 55 45 |  | 
| 56 46 | 
             
            But also, your `histories` table will be updated:
         | 
| 57 47 |  | 
| 58 | 
            -
            | id | 
| 59 | 
            -
            |  | 
| 60 | 
            -
            | 1 | 
| 61 | 
            -
            | 2 | 
| 62 | 
            -
            | 1 | 
| 48 | 
            +
            | id  | post_id | title                 | history_started_at | history_ended_at | history_user_id |
         | 
| 49 | 
            +
            | :-- | :------ | :-------------------- | :----------------- | :--------------- | :-------------- |
         | 
| 50 | 
            +
            | 1   | 1       | My Great Post         | '2019-11-08'       | '2019-11-09'     | 1               |
         | 
| 51 | 
            +
            | 2   | 2       | My Second Post        | '2019-11-08'       | NULL             | 1               |
         | 
| 52 | 
            +
            | 1   | 1       | Title With Better SEO | '2019-11-09'       | NULL             | 1               |
         | 
| 63 53 |  | 
| 64 54 | 
             
            A few things have happened here:
         | 
| 65 55 |  | 
| 66 56 | 
             
            1. The primary table (`posts`) is updated directly
         | 
| 67 57 | 
             
            2. The existing history for `post_id=1` is timestamped when its `history_ended_at`, so that we can see when the post had the title "My Great Post"
         | 
| 68 | 
            -
            3. A new history record is appended to the table containing a complete snapshot of the record, and a `NULL` `history_ended_at`. That's because this is the current history. | 
| 58 | 
            +
            3. A new history record is appended to the table containing a complete snapshot of the record, and a `NULL` `history_ended_at`. That's because this is the current history.
         | 
| 69 59 | 
             
            4. A record of _who_ made the change is saved (`history_user_id`). You can join to your users table to see more data.
         | 
| 70 60 |  | 
| 61 | 
            +
            ## Snapshots
         | 
| 62 | 
            +
             | 
| 63 | 
            +
            Snapshots are particularly useful for two key use cases:
         | 
| 64 | 
            +
             | 
| 65 | 
            +
            ### 1. Time Travel & Auditing
         | 
| 66 | 
            +
             | 
| 67 | 
            +
            When you need to see exactly what your data looked like at a specific point in time - not just individual records, but entire object graphs with all their associations. This is invaluable for:
         | 
| 68 | 
            +
             | 
| 69 | 
            +
            - Debugging production issues ("What did the entire order look like when this happened?")
         | 
| 70 | 
            +
            - Compliance requirements ("Show me the exact state of this patient's record on January 1st")
         | 
| 71 | 
            +
            - Auditing complex workflows ("What was the state of this loan application when it was approved?")
         | 
| 72 | 
            +
             | 
| 73 | 
            +
            ### 2. Machine Learning & Analytics
         | 
| 74 | 
            +
             | 
| 75 | 
            +
            When you need immutable snapshots of data for:
         | 
| 76 | 
            +
             | 
| 77 | 
            +
            - Training data versioning
         | 
| 78 | 
            +
            - Feature engineering
         | 
| 79 | 
            +
            - Model validation
         | 
| 80 | 
            +
            - A/B test analysis
         | 
| 81 | 
            +
            - Ensuring reproducibility of results
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            ### Taking Snapshots
         | 
| 84 | 
            +
             | 
| 85 | 
            +
            You can take a snapshot of a record and all its associated records:
         | 
| 86 | 
            +
             | 
| 87 | 
            +
            ```ruby
         | 
| 88 | 
            +
            post = Post.find(1)
         | 
| 89 | 
            +
            post.snapshot(history_user_id: current_user.id)
         | 
| 90 | 
            +
            ```
         | 
| 91 | 
            +
             | 
| 92 | 
            +
            This will:
         | 
| 93 | 
            +
             | 
| 94 | 
            +
            1. Create a history record for the post
         | 
| 95 | 
            +
            2. Create history records for all associated records (comments, author, etc.)
         | 
| 96 | 
            +
            3. Link these history records together with a shared `snapshot_id`
         | 
| 97 | 
            +
             | 
| 98 | 
            +
            You can retrieve the latest snapshot using:
         | 
| 99 | 
            +
             | 
| 100 | 
            +
            ```ruby
         | 
| 101 | 
            +
            post = Post.find(1)
         | 
| 102 | 
            +
            snapshot = post.latest_snapshot
         | 
| 103 | 
            +
             | 
| 104 | 
            +
            # Access associated records from the snapshot
         | 
| 105 | 
            +
            snapshot.comments # Returns CommentHistory records
         | 
| 106 | 
            +
            snapshot.author   # Returns AuthorHistory record
         | 
| 107 | 
            +
            ```
         | 
| 108 | 
            +
             | 
| 109 | 
            +
            Snapshots are immutable - you cannot modify history records that are part of a snapshot. This guarantees that your historical data remains unchanged, which is crucial for both auditing and machine learning applications.
         | 
| 110 | 
            +
             | 
| 111 | 
            +
            ### Snapshot-Only Mode
         | 
| 112 | 
            +
             | 
| 113 | 
            +
            If you want to only track snapshots and not record every individual change, you can configure Historiographer to operate in snapshot-only mode:
         | 
| 114 | 
            +
             | 
| 115 | 
            +
            ```ruby
         | 
| 116 | 
            +
            Historiographer::Configuration.mode = :snapshot_only
         | 
| 117 | 
            +
            ```
         | 
| 118 | 
            +
             | 
| 119 | 
            +
            In this mode:
         | 
| 120 | 
            +
             | 
| 121 | 
            +
            - Regular updates/changes will not create history records
         | 
| 122 | 
            +
            - Only explicit calls to `snapshot` will create history records
         | 
| 123 | 
            +
            - Each snapshot still captures the complete state of the record and its associations
         | 
| 124 | 
            +
             | 
| 125 | 
            +
            This can be useful when:
         | 
| 126 | 
            +
             | 
| 127 | 
            +
            - You only care about specific points in time rather than every change
         | 
| 128 | 
            +
            - You want to reduce the number of history records created
         | 
| 129 | 
            +
            - You need to capture the state of complex object graphs at specific moments
         | 
| 130 | 
            +
            - You're versioning training data for machine learning models
         | 
| 131 | 
            +
            - You need to maintain immutable audit trails at specific checkpoints
         | 
| 132 | 
            +
             | 
| 71 133 | 
             
            # Getting Started
         | 
| 72 134 |  | 
| 73 135 | 
             
            Whenever you include the `Historiographer` gem in your ActiveRecord model, it allows you to insert, update, or delete data as you normally would.
         | 
| @@ -78,18 +140,33 @@ class Post < ActiveRecord::Base | |
| 78 140 | 
             
            end
         | 
| 79 141 | 
             
            ```
         | 
| 80 142 |  | 
| 81 | 
            -
             | 
| 143 | 
            +
            ### History Modes
         | 
| 144 | 
            +
             | 
| 145 | 
            +
            Historiographer supports two modes of operation:
         | 
| 146 | 
            +
             | 
| 147 | 
            +
            1. **:histories mode** (default) - Records history for every change to a record
         | 
| 148 | 
            +
            2. **:snapshot_only mode** - Only records history when explicitly taking snapshots
         | 
| 149 | 
            +
             | 
| 150 | 
            +
            You can configure the mode globally:
         | 
| 82 151 |  | 
| 83 152 | 
             
            ```ruby
         | 
| 84 | 
            -
             | 
| 85 | 
            -
             | 
| 153 | 
            +
            # In an initializer
         | 
| 154 | 
            +
            Historiographer::Configuration.mode = :histories  # Default mode
         | 
| 155 | 
            +
            # or
         | 
| 156 | 
            +
            Historiographer::Configuration.mode = :snapshot_only
         | 
| 86 157 | 
             
            ```
         | 
| 87 158 |  | 
| 88 | 
            -
             | 
| 159 | 
            +
            Or per model using `historiographer_mode`:
         | 
| 89 160 |  | 
| 90 161 | 
             
            ```ruby
         | 
| 91 162 | 
             
            class Post < ActiveRecord::Base
         | 
| 92 | 
            -
              include Historiographer | 
| 163 | 
            +
              include Historiographer
         | 
| 164 | 
            +
              historiographer_mode :snapshot_only  # Only record history when .snapshot is called
         | 
| 165 | 
            +
            end
         | 
| 166 | 
            +
             | 
| 167 | 
            +
            class Comment < ActiveRecord::Base
         | 
| 168 | 
            +
              include Historiographer
         | 
| 169 | 
            +
              historiographer_mode :histories  # Record history for every change (default)
         | 
| 93 170 | 
             
            end
         | 
| 94 171 | 
             
            ```
         | 
| 95 172 |  | 
| @@ -210,12 +287,11 @@ end | |
| 210 287 | 
             
            == Mysql Install
         | 
| 211 288 |  | 
| 212 289 | 
             
            For contributors on OSX, you may have difficulty installing mysql:
         | 
| 213 | 
            -
             | 
| 290 | 
            +
             | 
| 214 291 | 
             
            ```
         | 
| 215 292 | 
             
            gem install mysql2 -v '0.4.10' --source 'https://rubygems.org/' -- --with-ldflags=-L/usr/local/opt/openssl/lib --with-cppflags=-I/usr/local/opt/openssl/include
         | 
| 216 293 | 
             
            ```
         | 
| 217 294 |  | 
| 218 | 
            -
             | 
| 219 295 | 
             
            == Copyright
         | 
| 220 296 |  | 
| 221 297 | 
             
            Copyright (c) 2016-2020 brettshollenberger. See LICENSE.txt for
         | 
| @@ -0,0 +1,36 @@ | |
| 1 | 
            +
            require "singleton"
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Historiographer
         | 
| 4 | 
            +
              class Configuration
         | 
| 5 | 
            +
                include Singleton
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                OPTS = {
         | 
| 8 | 
            +
                  mode: {
         | 
| 9 | 
            +
                    default: :histories
         | 
| 10 | 
            +
                  }
         | 
| 11 | 
            +
                }
         | 
| 12 | 
            +
                OPTS.each do |key, options|
         | 
| 13 | 
            +
                  attr_accessor key
         | 
| 14 | 
            +
                end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                class << self
         | 
| 17 | 
            +
                  def configure
         | 
| 18 | 
            +
                    yield instance
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  OPTS.each do |key, options|
         | 
| 22 | 
            +
                    define_method "#{key}=" do |value|
         | 
| 23 | 
            +
                      instance.send("#{key}=", value)
         | 
| 24 | 
            +
                    end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                    define_method key do
         | 
| 27 | 
            +
                      instance.send(key) || options.dig(:default)
         | 
| 28 | 
            +
                    end
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                def initialize
         | 
| 33 | 
            +
                  @mode = :histories
         | 
| 34 | 
            +
                end
         | 
| 35 | 
            +
              end
         | 
| 36 | 
            +
            end
         | 
| @@ -139,7 +139,7 @@ module Historiographer | |
| 139 139 | 
             
                  # If the record was not already persisted, proceed as normal.
         | 
| 140 140 | 
             
                  #
         | 
| 141 141 | 
             
                  def save(*args)
         | 
| 142 | 
            -
                    if persisted? && (changes.keys - %w(history_ended_at)).any?
         | 
| 142 | 
            +
                    if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
         | 
| 143 143 | 
             
                      false
         | 
| 144 144 | 
             
                    else
         | 
| 145 145 | 
             
                      super
         | 
| @@ -147,12 +147,19 @@ module Historiographer | |
| 147 147 | 
             
                  end
         | 
| 148 148 |  | 
| 149 149 | 
             
                  def save!(*args)
         | 
| 150 | 
            -
                    if persisted? && (changes.keys - %w(history_ended_at)).any?
         | 
| 150 | 
            +
                    if persisted? && (changes.keys - %w(history_ended_at snapshot_id)).any?
         | 
| 151 151 | 
             
                      false
         | 
| 152 152 | 
             
                    else
         | 
| 153 153 | 
             
                      super
         | 
| 154 154 | 
             
                    end
         | 
| 155 155 | 
             
                  end
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                  # Returns the most recent snapshot for each snapshot_id
         | 
| 158 | 
            +
                  # Orders by history_started_at and id to handle cases where multiple records
         | 
| 159 | 
            +
                  # have the same history_started_at timestamp
         | 
| 160 | 
            +
                  scope :latest_snapshot, -> {
         | 
| 161 | 
            +
                    where.not(snapshot_id: nil).order('id DESC').limit(1)&.first
         | 
| 162 | 
            +
                  }
         | 
| 156 163 | 
             
                end
         | 
| 157 164 |  | 
| 158 165 | 
             
                class_methods do
         | 
| @@ -29,22 +29,24 @@ module Historiographer | |
| 29 29 | 
             
                  original_columns = klass.columns.reject { |c| c.name == "id" || except.include?(c.name) || (only.any? && only.exclude?(c.name)) || no_business_columns }
         | 
| 30 30 |  | 
| 31 31 | 
             
                  integer foreign_key.to_sym, null: false
         | 
| 32 | 
            +
                  valid_keys = [:limit, :precision, :scale, :default, :null, :collation, :comment, 
         | 
| 33 | 
            +
                                :primary_key, :if_exists, :if_not_exists, :array, :using, 
         | 
| 34 | 
            +
                                :cast_as, :as, :type, :enum_type, :stored]
         | 
| 32 35 |  | 
| 33 36 | 
             
                  original_columns.each do |column|
         | 
| 34 | 
            -
                    opts =  | 
| 35 | 
            -
                    opts.merge!(column.as_json.clone)
         | 
| 37 | 
            +
                    opts = column.as_json.symbolize_keys.slice(*valid_keys) # Only keep valid keys
         | 
| 36 38 |  | 
| 37 39 | 
             
                    if RUBY_VERSION.to_i >= 3
         | 
| 38 | 
            -
                       | 
| 39 | 
            -
                      send(column.type, column.name, **opts.symbolize_keys!)
         | 
| 40 | 
            +
                      send(column.type, column.name, **opts)
         | 
| 40 41 | 
             
                    else
         | 
| 41 | 
            -
                      send(column.type, column.name, opts | 
| 42 | 
            +
                      send(column.type, column.name, opts)
         | 
| 42 43 | 
             
                    end
         | 
| 43 44 | 
             
                  end
         | 
| 44 45 |  | 
| 45 46 | 
             
                  datetime :history_started_at, null: false
         | 
| 46 47 | 
             
                  datetime :history_ended_at
         | 
| 47 48 | 
             
                  integer :history_user_id
         | 
| 49 | 
            +
                  string :snapshot_id
         | 
| 48 50 |  | 
| 49 51 | 
             
                  indices_sql = %q(
         | 
| 50 52 | 
             
                    SELECT 
         | 
| @@ -75,7 +77,8 @@ module Historiographer | |
| 75 77 | 
             
                    foreign_key,
         | 
| 76 78 | 
             
                    :history_started_at,
         | 
| 77 79 | 
             
                    :history_ended_at,
         | 
| 78 | 
            -
                    :history_user_id
         | 
| 80 | 
            +
                    :history_user_id,
         | 
| 81 | 
            +
                    :snapshot_id
         | 
| 79 82 | 
             
                  ])
         | 
| 80 83 |  | 
| 81 84 | 
             
                  indexes.each do |index_definition|
         | 
    
        data/lib/historiographer.rb
    CHANGED
    
    | @@ -1,6 +1,7 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 3 | 
             
            require 'active_support/all'
         | 
| 4 | 
            +
            require 'securerandom'
         | 
| 4 5 | 
             
            require_relative './historiographer/history'
         | 
| 5 6 | 
             
            require_relative './historiographer/postgres_migration'
         | 
| 6 7 | 
             
            require_relative './historiographer/safe'
         | 
| @@ -83,12 +84,21 @@ module Historiographer | |
| 83 84 | 
             
                after_save :record_history, if: :should_record_history?
         | 
| 84 85 | 
             
                validate :validate_history_user_id_present, if: :should_validate_history_user_id_present?
         | 
| 85 86 |  | 
| 87 | 
            +
                # Add scope to fetch latest histories
         | 
| 88 | 
            +
                scope :latest_snapshot, -> {
         | 
| 89 | 
            +
                  history_class.latest_snapshot
         | 
| 90 | 
            +
                }
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                def should_alert_history_user_id_present?
         | 
| 93 | 
            +
                  !snapshot_mode? && !is_history_class? && Thread.current[:skip_history_user_id_validation] != true
         | 
| 94 | 
            +
                end
         | 
| 95 | 
            +
             | 
| 86 96 | 
             
                def should_validate_history_user_id_present?
         | 
| 87 | 
            -
                  true
         | 
| 97 | 
            +
                  !snapshot_mode? && !is_history_class? && Thread.current[:skip_history_user_id_validation] != true
         | 
| 88 98 | 
             
                end
         | 
| 89 99 |  | 
| 90 100 | 
             
                def validate_history_user_id_present
         | 
| 91 | 
            -
                  if @no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer))
         | 
| 101 | 
            +
                  if should_validate_history_user_id_present? && (@no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer)))
         | 
| 92 102 | 
             
                    errors.add(:history_user_id, 'must be an integer')
         | 
| 93 103 | 
             
                  end
         | 
| 94 104 | 
             
                end
         | 
| @@ -139,9 +149,9 @@ module Historiographer | |
| 139 149 | 
             
                def historiographer_changes?
         | 
| 140 150 | 
             
                  case Rails.version.to_f
         | 
| 141 151 | 
             
                  when 0..5 then changed? && valid?
         | 
| 142 | 
            -
                  when 5.1..7 then saved_changes?
         | 
| 143 | 
            -
                  else
         | 
| 144 152 | 
             
                    raise 'Unsupported Rails version'
         | 
| 153 | 
            +
                  when 5.1..8 then saved_changes?
         | 
| 154 | 
            +
                  else
         | 
| 145 155 | 
             
                  end
         | 
| 146 156 | 
             
                end
         | 
| 147 157 |  | 
| @@ -151,17 +161,36 @@ module Historiographer | |
| 151 161 | 
             
                # then record history after successful save.
         | 
| 152 162 | 
             
                #
         | 
| 153 163 | 
             
                def should_record_history?
         | 
| 164 | 
            +
                  return false if snapshot_mode?
         | 
| 165 | 
            +
                  return false if is_history_class?
         | 
| 166 | 
            +
             | 
| 154 167 | 
             
                  historiographer_changes? && !@no_history
         | 
| 155 168 | 
             
                end
         | 
| 156 169 |  | 
| 157 | 
            -
                 | 
| 170 | 
            +
                def history_user_id=(value)
         | 
| 171 | 
            +
                  if is_history_class?
         | 
| 172 | 
            +
                    write_attribute(:history_user_id, value)
         | 
| 173 | 
            +
                  else
         | 
| 174 | 
            +
                    @history_user_id = value
         | 
| 175 | 
            +
                  end
         | 
| 176 | 
            +
                end
         | 
| 177 | 
            +
             | 
| 178 | 
            +
                def history_user_id
         | 
| 179 | 
            +
                  if is_history_class?
         | 
| 180 | 
            +
                    read_attribute(:history_user_id)
         | 
| 181 | 
            +
                  else
         | 
| 182 | 
            +
                    @history_user_id
         | 
| 183 | 
            +
                  end
         | 
| 184 | 
            +
                end
         | 
| 158 185 |  | 
| 159 186 | 
             
                class_name = "#{base.name}History"
         | 
| 160 187 |  | 
| 161 188 | 
             
                begin
         | 
| 162 189 | 
             
                  class_name.constantize
         | 
| 163 190 | 
             
                rescue StandardError
         | 
| 164 | 
            -
                  history_class_initializer = Class.new( | 
| 191 | 
            +
                  history_class_initializer = Class.new(base) do
         | 
| 192 | 
            +
                    self.table_name = "#{base.table_name}_histories"
         | 
| 193 | 
            +
                    self.inheritance_column = nil
         | 
| 165 194 | 
             
                  end
         | 
| 166 195 |  | 
| 167 196 | 
             
                  Object.const_set(class_name, history_class_initializer)
         | 
| @@ -169,6 +198,70 @@ module Historiographer | |
| 169 198 |  | 
| 170 199 | 
             
                klass = class_name.constantize
         | 
| 171 200 |  | 
| 201 | 
            +
                # Hook into the association building process
         | 
| 202 | 
            +
                base.singleton_class.prepend(Module.new do
         | 
| 203 | 
            +
                  def belongs_to(name, scope = nil, **options, &extension)
         | 
| 204 | 
            +
                    super
         | 
| 205 | 
            +
                    define_history_association(name, :belongs_to, options)
         | 
| 206 | 
            +
                  end
         | 
| 207 | 
            +
             | 
| 208 | 
            +
                  def has_one(name, scope = nil, **options, &extension)
         | 
| 209 | 
            +
                    super
         | 
| 210 | 
            +
                    define_history_association(name, :has_one, options)
         | 
| 211 | 
            +
                  end
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                  def has_many(name, scope = nil, **options, &extension)
         | 
| 214 | 
            +
                    super
         | 
| 215 | 
            +
                    define_history_association(name, :has_many, options)
         | 
| 216 | 
            +
                  end
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                  def has_and_belongs_to_many(name, scope = nil, **options, &extension)
         | 
| 219 | 
            +
                    super
         | 
| 220 | 
            +
                    define_history_association(name, :has_and_belongs_to_many, options)
         | 
| 221 | 
            +
                  end
         | 
| 222 | 
            +
             | 
| 223 | 
            +
                  private
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                  def define_history_association(name, type, options)
         | 
| 226 | 
            +
                    return if is_history_class?
         | 
| 227 | 
            +
                    return if @defining_association
         | 
| 228 | 
            +
                    return if %i[histories current_history].include?(name)
         | 
| 229 | 
            +
                    @defining_association = true
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                    history_class = "#{self.name}History".constantize
         | 
| 232 | 
            +
                    history_class_name = "#{name.to_s.singularize.camelize}History"
         | 
| 233 | 
            +
             | 
| 234 | 
            +
                    # Get the original association's foreign key
         | 
| 235 | 
            +
                    original_reflection = self.reflect_on_association(name)
         | 
| 236 | 
            +
                    foreign_key = original_reflection.foreign_key
         | 
| 237 | 
            +
             | 
| 238 | 
            +
                    if type == :has_many || type == :has_and_belongs_to_many
         | 
| 239 | 
            +
                      history_class.send(
         | 
| 240 | 
            +
                        type, 
         | 
| 241 | 
            +
                        name, 
         | 
| 242 | 
            +
                        -> (owner) { where("#{name.to_s.singularize}_histories.snapshot_id = ?", owner.snapshot_id) }, 
         | 
| 243 | 
            +
                        **options.merge(
         | 
| 244 | 
            +
                          class_name: history_class_name, 
         | 
| 245 | 
            +
                          foreign_key: foreign_key,
         | 
| 246 | 
            +
                          primary_key: foreign_key
         | 
| 247 | 
            +
                        )
         | 
| 248 | 
            +
                      )
         | 
| 249 | 
            +
                    else
         | 
| 250 | 
            +
                      history_class.send(
         | 
| 251 | 
            +
                        type, 
         | 
| 252 | 
            +
                        name, 
         | 
| 253 | 
            +
                        -> (owner) { where("#{name}_histories.snapshot_id = ?", owner.snapshot_id) }, 
         | 
| 254 | 
            +
                        **options.merge(
         | 
| 255 | 
            +
                          class_name: history_class_name, 
         | 
| 256 | 
            +
                          foreign_key: foreign_key,
         | 
| 257 | 
            +
                          primary_key: foreign_key
         | 
| 258 | 
            +
                        )
         | 
| 259 | 
            +
                      )
         | 
| 260 | 
            +
                    end
         | 
| 261 | 
            +
                    @defining_association = false
         | 
| 262 | 
            +
                  end
         | 
| 263 | 
            +
                end)
         | 
| 264 | 
            +
             | 
| 172 265 | 
             
                if base.respond_to?(:histories)
         | 
| 173 266 | 
             
                  raise "#{base} already has histories. Talk to Brett if this is a legit use case."
         | 
| 174 267 | 
             
                else
         | 
| @@ -219,6 +312,46 @@ module Historiographer | |
| 219 312 | 
             
                  @no_history = false
         | 
| 220 313 | 
             
                end
         | 
| 221 314 |  | 
| 315 | 
            +
                
         | 
| 316 | 
            +
                def snapshot(tree = {}, snapshot_id = nil)
         | 
| 317 | 
            +
                  return if is_history_class?
         | 
| 318 | 
            +
             | 
| 319 | 
            +
                  without_history_user_id do
         | 
| 320 | 
            +
                    # Use SecureRandom.uuid instead of timestamp for snapshot_id
         | 
| 321 | 
            +
                    snapshot_id ||= SecureRandom.uuid
         | 
| 322 | 
            +
                    history_class = self.class.history_class
         | 
| 323 | 
            +
                    primary_key = self.class.primary_key
         | 
| 324 | 
            +
                    foreign_key = history_class.history_foreign_key
         | 
| 325 | 
            +
                    attrs = attributes.clone
         | 
| 326 | 
            +
                    existing_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: snapshot_id)
         | 
| 327 | 
            +
                    return if existing_snapshot.present?
         | 
| 328 | 
            +
             | 
| 329 | 
            +
                    null_snapshot = history_class.where(foreign_key => attrs[primary_key], snapshot_id: nil)
         | 
| 330 | 
            +
                    if null_snapshot.present?
         | 
| 331 | 
            +
                      null_snapshot.update(snapshot_id: snapshot_id)
         | 
| 332 | 
            +
                    else
         | 
| 333 | 
            +
                      record_history(snapshot_id: snapshot_id)
         | 
| 334 | 
            +
                    end
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                    # Recursively snapshot associations, avoiding infinite loops
         | 
| 337 | 
            +
                    self.class.reflect_on_all_associations.each do |association|
         | 
| 338 | 
            +
                      associated_records = send(association.name).reload
         | 
| 339 | 
            +
                      Array(associated_records).each do |record|
         | 
| 340 | 
            +
                        model_name = record.class.name
         | 
| 341 | 
            +
                        record_id = record.id
         | 
| 342 | 
            +
             | 
| 343 | 
            +
                        tree[model_name] ||= {}
         | 
| 344 | 
            +
                        next if tree[model_name][record_id]
         | 
| 345 | 
            +
             | 
| 346 | 
            +
                        new_tree = tree.deep_dup
         | 
| 347 | 
            +
                        new_tree[model_name][record_id] = true
         | 
| 348 | 
            +
             | 
| 349 | 
            +
                        record.snapshot(new_tree, snapshot_id) if record.respond_to?(:snapshot)
         | 
| 350 | 
            +
                      end
         | 
| 351 | 
            +
                    end
         | 
| 352 | 
            +
                  end
         | 
| 353 | 
            +
                end
         | 
| 354 | 
            +
             | 
| 222 355 | 
             
                private
         | 
| 223 356 |  | 
| 224 357 | 
             
                def history_user_absent_action
         | 
| @@ -231,8 +364,8 @@ module Historiographer | |
| 231 364 | 
             
                #
         | 
| 232 365 | 
             
                # Find the most recent history, and update its history_ended_at timestamp
         | 
| 233 366 | 
             
                #
         | 
| 234 | 
            -
                def record_history
         | 
| 235 | 
            -
                  history_user_absent_action if history_user_id.nil?
         | 
| 367 | 
            +
                def record_history(snapshot_id: nil)
         | 
| 368 | 
            +
                  history_user_absent_action if history_user_id.nil? && should_alert_history_user_id_present?
         | 
| 236 369 |  | 
| 237 370 | 
             
                  attrs = attributes.clone
         | 
| 238 371 | 
             
                  history_class = self.class.history_class
         | 
| @@ -240,30 +373,62 @@ module Historiographer | |
| 240 373 |  | 
| 241 374 | 
             
                  now = UTC.now
         | 
| 242 375 | 
             
                  attrs.merge!(foreign_key => attrs['id'], history_started_at: now, history_user_id: history_user_id)
         | 
| 376 | 
            +
                  attrs.merge!(snapshot_id: snapshot_id) if snapshot_id.present?
         | 
| 243 377 |  | 
| 244 378 | 
             
                  attrs = attrs.except('id')
         | 
| 245 379 |  | 
| 246 380 | 
             
                  current_history = histories.where(history_ended_at: nil).order('id desc').limit(1).last
         | 
| 247 381 |  | 
| 248 382 | 
             
                  if foreign_key.present? && history_class.present?
         | 
| 249 | 
            -
                    history_class.create!(attrs)
         | 
| 250 | 
            -
             | 
| 383 | 
            +
                    history_class.create!(attrs).tap do |history|
         | 
| 384 | 
            +
                      current_history.update!(history_ended_at: now) if current_history.present?
         | 
| 385 | 
            +
                    end
         | 
| 251 386 | 
             
                  else
         | 
| 252 387 | 
             
                    raise 'Need foreign key and history class to save history!'
         | 
| 253 388 | 
             
                  end
         | 
| 254 389 | 
             
                end
         | 
| 390 | 
            +
             | 
| 391 | 
            +
                def without_history_user_id
         | 
| 392 | 
            +
                  Thread.current[:skip_history_user_id_validation] = true
         | 
| 393 | 
            +
                  yield
         | 
| 394 | 
            +
                ensure
         | 
| 395 | 
            +
                  Thread.current[:skip_history_user_id_validation] = false
         | 
| 396 | 
            +
                end
         | 
| 255 397 | 
             
              end
         | 
| 256 398 |  | 
| 257 399 | 
             
              class_methods do
         | 
| 400 | 
            +
                def is_history_class?
         | 
| 401 | 
            +
                  name.match?(/History$/)
         | 
| 402 | 
            +
                end
         | 
| 258 403 | 
             
                #
         | 
| 259 404 | 
             
                # E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory
         | 
| 260 405 | 
             
                #
         | 
| 261 406 | 
             
                def history_class
         | 
| 262 | 
            -
                   | 
| 407 | 
            +
                  if is_history_class?
         | 
| 408 | 
            +
                    nil
         | 
| 409 | 
            +
                  else
         | 
| 410 | 
            +
                    "#{name}History".constantize
         | 
| 411 | 
            +
                  end
         | 
| 263 412 | 
             
                end
         | 
| 264 413 |  | 
| 265 414 | 
             
                def relation
         | 
| 266 415 | 
             
                  super.tap { |r| r.extend Historiographer::Relation }
         | 
| 267 416 | 
             
                end
         | 
| 417 | 
            +
             | 
| 418 | 
            +
                def historiographer_mode(mode)
         | 
| 419 | 
            +
                  @historiographer_mode = mode
         | 
| 420 | 
            +
                end
         | 
| 421 | 
            +
             | 
| 422 | 
            +
                def get_historiographer_mode
         | 
| 423 | 
            +
                  @historiographer_mode || Historiographer::Configuration.mode
         | 
| 424 | 
            +
                end
         | 
| 425 | 
            +
              end
         | 
| 426 | 
            +
             | 
| 427 | 
            +
              def is_history_class?
         | 
| 428 | 
            +
                self.class.is_history_class?
         | 
| 429 | 
            +
              end
         | 
| 430 | 
            +
             | 
| 431 | 
            +
              def snapshot_mode?
         | 
| 432 | 
            +
                (self.class.get_historiographer_mode.to_sym == :snapshot_only)
         | 
| 268 433 | 
             
              end
         | 
| 269 434 | 
             
            end
         | 
    
        metadata
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: historiographer
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 4. | 
| 4 | 
            +
              version: 4.1.1
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - brettshollenberger
         | 
| @@ -228,20 +228,10 @@ extra_rdoc_files: | |
| 228 228 | 
             
            - LICENSE.txt
         | 
| 229 229 | 
             
            - README.md
         | 
| 230 230 | 
             
            files:
         | 
| 231 | 
            -
            - ".document"
         | 
| 232 | 
            -
            - ".rspec"
         | 
| 233 | 
            -
            - ".ruby-version"
         | 
| 234 | 
            -
            - ".standalone_migrations"
         | 
| 235 | 
            -
            - Gemfile
         | 
| 236 | 
            -
            - Gemfile.lock
         | 
| 237 | 
            -
            - Guardfile
         | 
| 238 231 | 
             
            - LICENSE.txt
         | 
| 239 232 | 
             
            - README.md
         | 
| 240 | 
            -
            - Rakefile
         | 
| 241 | 
            -
            - VERSION
         | 
| 242 | 
            -
            - historiographer.gemspec
         | 
| 243 | 
            -
            - init.rb
         | 
| 244 233 | 
             
            - lib/historiographer.rb
         | 
| 234 | 
            +
            - lib/historiographer/configuration.rb
         | 
| 245 235 | 
             
            - lib/historiographer/history.rb
         | 
| 246 236 | 
             
            - lib/historiographer/history_migration.rb
         | 
| 247 237 | 
             
            - lib/historiographer/history_migration_mysql.rb
         | 
| @@ -250,24 +240,7 @@ files: | |
| 250 240 | 
             
            - lib/historiographer/relation.rb
         | 
| 251 241 | 
             
            - lib/historiographer/safe.rb
         | 
| 252 242 | 
             
            - lib/historiographer/silent.rb
         | 
| 253 | 
            -
            -  | 
| 254 | 
            -
            - spec/db/migrate/20161121212228_create_posts.rb
         | 
| 255 | 
            -
            - spec/db/migrate/20161121212229_create_post_histories.rb
         | 
| 256 | 
            -
            - spec/db/migrate/20161121212230_create_authors.rb
         | 
| 257 | 
            -
            - spec/db/migrate/20161121212231_create_author_histories.rb
         | 
| 258 | 
            -
            - spec/db/migrate/20161121212232_create_users.rb
         | 
| 259 | 
            -
            - spec/db/migrate/20171011194624_create_safe_posts.rb
         | 
| 260 | 
            -
            - spec/db/migrate/20171011194715_create_safe_post_histories.rb
         | 
| 261 | 
            -
            - spec/db/migrate/20191024142304_create_thing_with_compound_index.rb
         | 
| 262 | 
            -
            - spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb
         | 
| 263 | 
            -
            - spec/db/migrate/20191024203106_create_thing_without_history.rb
         | 
| 264 | 
            -
            - spec/db/migrate/20221018204220_create_silent_posts.rb
         | 
| 265 | 
            -
            - spec/db/migrate/20221018204255_create_silent_post_histories.rb
         | 
| 266 | 
            -
            - spec/db/schema.rb
         | 
| 267 | 
            -
            - spec/examples.txt
         | 
| 268 | 
            -
            - spec/factories/post.rb
         | 
| 269 | 
            -
            - spec/historiographer_spec.rb
         | 
| 270 | 
            -
            - spec/spec_helper.rb
         | 
| 243 | 
            +
            - lib/historiographer/version.rb
         | 
| 271 244 | 
             
            homepage: http://github.com/brettshollenberger/historiographer
         | 
| 272 245 | 
             
            licenses:
         | 
| 273 246 | 
             
            - MIT
         |