module Historiographer

Historiographer takes “histories” (think audits or snapshots) of your model whenever you make changes.

Core business data stored in histories can never be changed or destroyed (at least not from Rails-land), offering you a little more peace of mind (just a little).

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

A little example:

photo = Photo.create(file: “cool.jpg”) photo.histories # => [ <#PhotoHistory file: cool.jpg > ]

photo.file = “fun.jpg” photo.save! photo.histories.reload # => [ <#PhotoHistory file: cool.jpg >, <#PhotoHistory file: fun.jpg> ]

photo.histories.last.destroy! # => false

photo.histories.last.update!(file: “bad.jpg”) # => false

photo.histories.last.file = “bad.jpg” photo.histories.last.save! # => false

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

How to use:

1) Add historiographer to your apps dependencies folder

Ex: sudo ln -s ../../../shared/historiographer historiographer

2) Add historiographer to your apps gem file and bundle

gem 'historiographer', path: 'dependencies/historiographer', require: 'historiographer'

3) Create a primary table

create_table :photos do |t|

t.string :file

end

4) Create history table. 't.histories' pulls in all the primary tables attributes plus a few required by historiographer.

require “historiographer/postgres_migration” class CreatePhotoHistories < ActiveRecord::Migration

def change
  create_table :photo_histories do |t|
    t.histories
  end
end

end

5) Include Historiographer in the primary class:

class Photo < ActiveRecord::Base

include Historiographer

end

6) Create a history class

class PhotoHistory < ActiveRecord::Base (or whereever your app inherits from. Ex CPG: CpgConnection, Shoppers: ShoppersRecord, etc) end

7) Enjoy!

See Historiographer for more details

Historiographer::History is a mixin that is automatically included in any History class (e.g. RetailerProductHistory).

A History record represents a snapshot of a primary record at a particular point in time.

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

E.g. You have a RetailerProduct (ID: 1) that makes the following changes:

1) rp = RetailerProduct.create(name: “Sabra”)

2) rp.update(name: “Sabra Hummus”)

3) rp.update(name: “Sabra Pine Nut Hummus”)

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

Your RetailerProduct record looks like this:

<#RetailerProduct:0x007fbf00c78f00 name: “Sabra Pine Nut Hummus”>

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

But your RetailerProductHistories look like this:

rp.histories

<#RetailerProductHistory:0x007fbf00c78f01 name: “Sabra”, history_started_at: 1.minute.ago, history_ended_at: 30.seconds.ago> <#RetailerProductHistory:0x007fbf00c78f02 name: “Sabra Hummus”, history_started_at: 30.seconds.ago, history_ended_at: 10.seconds.ago> <#RetailerProductHistory:0x007fbf00c78f03 name: “Sabra Pine Nut Hummus”, history_started_at: 10.seconds.ago, history_ended_at: nil>

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

Since these Histories are intended to represent a snapshot in time, they should never be deleted or modified directly. Historiographer will manage all of the nuances for you.

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

Your classes should be written like this:

class RetailerProduct < ActiveRecord::Base

include Historiographer

end

# This class is created automatically. You don't # need to create a file yourself, unless you # want to add additional methods. # class RetailerProductHistory < ActiveRecord::Base

include Historiographer::History

end

Historiographer::Safe is intended to be used to migrate an existing model to Historiographer, not as a long-term solution.

Historiographer will throw an error if a model is saved without a user present, unless you explicitly call save_without_history.

Historiographer::Safe will not throw an error, but will rather produce a Rollbar, which enables a programmer to find all locations that need to be migrated, rather than allowing an unsafe migration to take place.

Eventually the programmer is expected to replace Safe with Historiographer so that future programmers will get an error if they try to save without user_id.

Constants

UTC

Public Instance Methods

assign_attributes(new_attributes) click to toggle source
Calls superclass method
# File lib/historiographer.rb, line 111
def assign_attributes(new_attributes)
  huid = new_attributes[:history_user_id]

  if huid.present?
    self.class.nested_attributes_options.each do |association, _|
      reflection  = self.class.reflect_on_association(association)
      assoc_attrs = new_attributes["#{association}_attributes"]

      if assoc_attrs.present?
        if reflection.collection?
          assoc_attrs.values.each do |hash|
            hash.merge!(history_user_id: huid)
          end
        else
          assoc_attrs.merge!(history_user_id: huid)
        end
      end
    end
  end

  super
end
destroy_with_history(history_user_id: nil) click to toggle source
# File lib/historiographer.rb, line 94
def destroy_with_history(history_user_id: nil)
  history_user_absent_action if history_user_id.nil?

  current_history = histories.where(history_ended_at: nil).order("id desc").limit(1).last
  current_history.update!(history_ended_at: UTC.now) if current_history.present?

  if respond_to?(:paranoia_destroy)
    self.history_user_id = history_user_id
    paranoia_destroy
  else
    @no_history = true
    destroy_without_history
    @no_history = false
  end
end
historiographer_changes?() click to toggle source
# File lib/historiographer.rb, line 134
def historiographer_changes?
  case Rails.version.to_f
  when 0..5 then changed? && valid?
  when 5.1..6 then saved_changes?
  else
    raise "Unsupported Rails version"
  end
end
history_class() click to toggle source

E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory

# File lib/historiographer.rb, line 252
def history_class
  "#{name}History".constantize
end
history_user_absent_action() click to toggle source
# File lib/historiographer.rb, line 214
def history_user_absent_action
  raise HistoryUserIdMissingError.new("history_user_id must be passed in order to save record with histories! If you are in a context with no history_user_id, explicitly call #save_without_user")
end
record_history() click to toggle source

Save a record of the most recent changes, with the current time as history_started_at, and the provided user as history_user_id.

Find the most recent history, and update its history_ended_at timestamp

# File lib/historiographer.rb, line 224
def record_history
  history_user_absent_action if history_user_id.nil?

  attrs         = attributes.clone
  history_class = self.class.history_class
  foreign_key   = history_class.history_foreign_key

  now = UTC.now
  attrs.merge!(foreign_key => attrs["id"], history_started_at: now, history_user_id: history_user_id)

  attrs = attrs.except("id")

  current_history = histories.where(history_ended_at: nil).order("id desc").limit(1).last

  unless foreign_key.present? && history_class.present?
    raise "Need foreign key and history class to save history!"
  else
    history_class.create!(attrs)
    current_history.update!(history_ended_at: now) if current_history.present?
  end
end
relation() click to toggle source
Calls superclass method
# File lib/historiographer.rb, line 256
def relation
  super.tap { |r| r.extend Historiographer::Relation }
end
save_without_history(*args, &block) click to toggle source
# File lib/historiographer.rb, line 200
def save_without_history(*args, &block)
  @no_history = true
  save(*args, &block)
  @no_history = false
end
save_without_history!(*args, &block) click to toggle source
# File lib/historiographer.rb, line 206
def save_without_history!(*args, &block)
  @no_history = true
  save!(*args, &block)
  @no_history = false
end
should_record_history?() click to toggle source

If there are any changes, and the model is valid, and we're not force-overriding history recording, then record history after successful save.

# File lib/historiographer.rb, line 147
def should_record_history?
  historiographer_changes? && !@no_history
end
should_validate_history_user_id_present?() click to toggle source
# File lib/historiographer.rb, line 83
def should_validate_history_user_id_present?
  true
end
validate_history_user_id_present() click to toggle source
# File lib/historiographer.rb, line 87
def validate_history_user_id_present
  if @no_history.nil? && (!history_user_id.present? || !history_user_id.is_a?(Integer))
    errors.add(:history_user_id, "must be an integer")
  end
end