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
# 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
# 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
# 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
E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory
# File lib/historiographer.rb, line 252 def history_class "#{name}History".constantize end
# 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
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
# File lib/historiographer.rb, line 256 def relation super.tap { |r| r.extend Historiographer::Relation } end
# File lib/historiographer.rb, line 200 def save_without_history(*args, &block) @no_history = true save(*args, &block) @no_history = false end
# File lib/historiographer.rb, line 206 def save_without_history!(*args, &block) @no_history = true save!(*args, &block) @no_history = false end
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
# File lib/historiographer.rb, line 83 def should_validate_history_user_id_present? true end
# 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