# frozen_string_literal: true require 'active_support/all' require_relative './historiographer/history' require_relative './historiographer/postgres_migration' require_relative './historiographer/safe' require_relative './historiographer/relation' require_relative './historiographer/silent' # 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! # module Historiographer extend ActiveSupport::Concern class HistoryUserIdMissingError < StandardError; end UTC = Time.now.in_time_zone('UTC').time_zone included do |base| after_save :record_history, if: :should_record_history? validate :validate_history_user_id_present, if: :should_validate_history_user_id_present? def should_validate_history_user_id_present? true end 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 alias_method :destroy_without_history, :destroy 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 alias_method :destroy, :destroy_with_history 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 def historiographer_changes? case Rails.version.to_f when 0..5 then changed? && valid? when 5.1..7 then saved_changes? else raise 'Unsupported Rails version' end 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. # def should_record_history? historiographer_changes? && !@no_history end attr_accessor :history_user_id class_name = "#{base.name}History" begin class_name.constantize rescue StandardError history_class_initializer = Class.new(ActiveRecord::Base) do end Object.const_set(class_name, history_class_initializer) end klass = class_name.constantize if base.respond_to?(:histories) raise "#{base} already has histories. Talk to Brett if this is a legit use case." else opts = { class_name: class_name } opts[:foreign_key] = klass.history_foreign_key if klass.respond_to?(:history_foreign_key) has_many :histories, opts has_one :current_history, -> { current }, opts end klass.send(:include, Historiographer::History) unless klass.ancestors.include?(Historiographer::History) # # The acts_as_paranoid gem, which we tend to use with our History classes, # uses update_columns to update deleted_at fields. # # In order to make sure these changes are persisted into Histories objects, # we also have to record history here. # module UpdateColumnsWithHistory def update_columns(*args) opts = args.extract_options! any_changes = opts.keys.reject { |k| k == 'id' }.any? transaction do persisted = super(opts) record_history if any_changes && persisted end end end base.send(:prepend, UpdateColumnsWithHistory) def save_without_history(*args, &block) @no_history = true save(*args, &block) @no_history = false end def save_without_history!(*args, &block) @no_history = true save!(*args, &block) @no_history = false end private def history_user_absent_action raise HistoryUserIdMissingError, '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 # 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 if foreign_key.present? && history_class.present? history_class.create!(attrs) current_history.update!(history_ended_at: now) if current_history.present? else raise 'Need foreign key and history class to save history!' end end end class_methods do # # E.g. SponsoredProductCampaign => SponsoredProductCampaignHistory # def history_class "#{name}History".constantize end def relation super.tap { |r| r.extend Historiographer::Relation } end end end