lib/chrono_model/time_machine.rb
# frozen_string_literal: true
require 'chrono_model/time_machine/time_query'
require 'chrono_model/time_machine/timeline'
require 'chrono_model/time_machine/history_model'
module ChronoModel
module TimeMachine
include ChronoModel::Patches::AsOfTimeHolder
extend ActiveSupport::Concern
included do
if table_exists? && !chrono?
logger.warn <<-MSG.squish
ChronoModel: #{table_name} is not a temporal table.
Please use `change_table :#{table_name}, temporal: true` in a migration.
MSG
end
history = ChronoModel::TimeMachine.define_history_model_for(self)
ChronoModel.history_models[table_name] = history
class << self
def subclasses(with_history: false)
subclasses = super()
subclasses.reject!(&:history?) unless with_history
subclasses
end
def subclasses_with_history
subclasses(with_history: true)
end
# `direct_descendants` is deprecated method in 7.0 and has been
# removed in 7.1
if method_defined?(:direct_descendants)
alias_method :direct_descendants_with_history, :subclasses_with_history
alias_method :direct_descendants, :subclasses
end
# Ruby 3.1 has a native subclasses method and descendants is
# implemented with recursion of subclasses
if Class.method_defined?(:subclasses)
def descendants_with_history
subclasses_with_history.concat(subclasses.flat_map(&:descendants_with_history))
end
end
def descendants
descendants_with_history.reject(&:history?)
end
# STI support.
#
def inherited(subclass)
super
# Do not smash stack. The below +define_history_model_for+ method
# defines a new inherited class via Class.new(), thus +inherited+
# is going to be called again. By that time the subclass is still
# an anonymous one, so its +name+ method returns nil. We use this
# condition to avoid infinite recursion.
#
# Sadly, we can't avoid it by calling +.history?+, because in the
# subclass the HistoryModel hasn't been included yet.
#
return if subclass.name.nil?
ChronoModel::TimeMachine.define_history_model_for(subclass)
end
end
end
def self.define_history_model_for(model)
history = Class.new(model) do
include ChronoModel::TimeMachine::HistoryModel
end
model.singleton_class.instance_eval do
define_method(:history) { history }
end
model.const_set :History, history
end
module ClassMethods
# Identify this class as the parent, non-history, class.
#
def history?
false
end
# Returns an ActiveRecord::Relation on the history of this model as
# it was +time+ ago.
delegate :as_of, to: :history
def attribute_names_for_history_changes
@attribute_names_for_history_changes ||= attribute_names -
%w[id hid validity recorded_at]
end
def has_timeline(options)
changes = options.delete(:changes)
assocs = history.has_timeline(options)
attributes =
if changes.present?
Array.wrap(changes)
else
assocs.map(&:name)
end
attribute_names_for_history_changes.concat(attributes.map(&:to_s))
end
delegate :timeline_associations, to: :history
end
# Returns a read-only representation of this record as it was +time+ ago.
# Returns nil if no record is found.
#
def as_of(time)
_as_of(time).first
end
# Returns a read-only representation of this record as it was +time+ ago.
# Raises ActiveRecord::RecordNotFound if no record is found.
#
def as_of!(time)
_as_of(time).first!
end
# Delegates to +HistoryModel::ClassMethods.as_of+ to fetch this instance
# as it was on +time+. Used both by +as_of+ and +as_of!+ for performance
# reasons, to avoid a `rescue` (@lleirborras).
#
def _as_of(time)
self.class.as_of(time).where(id: id)
end
protected :_as_of
# Return the complete read-only history of this instance.
#
def history
self.class.history.chronological.of(self)
end
# Returns an Array of timestamps for which this instance has an history
# record. Takes temporal associations into account.
#
def timeline(options = {})
self.class.history.timeline(self, options)
end
# Returns a boolean indicating whether this record is an history entry.
#
def historical?
as_of_time.present?
end
# Inhibit destroy of historical records
#
def destroy
raise ActiveRecord::ReadOnlyRecord, 'Cannot delete historical records' if historical?
super
end
# Returns the previous record in the history, or nil if this is the only
# recorded entry.
#
def pred(options = {})
if self.class.timeline_associations.empty?
history.reverse_order.second
else
return nil unless (ts = pred_timestamp(options))
order_clause = Arel.sql %[ LOWER(#{options[:table] || self.class.quoted_table_name}."validity") DESC ]
self.class.as_of(ts).order(order_clause).find(options[:id] || id)
end
end
# Returns the previous timestamp in this record's timeline. Includes
# temporal associations.
#
def pred_timestamp(options = {})
if historical?
options[:before] ||= as_of_time
timeline(options.merge(limit: 1, reverse: true)).first
else
timeline(options.merge(limit: 2, reverse: true)).second
end
end
# This is a current record, so its next instance is always nil.
#
def succ
nil
end
# Returns the current history version
#
def current_version
if historical?
self.class.find(id)
else
self
end
end
# Returns the differences between this entry and the previous history one.
# See: +changes_against+.
#
def last_changes
pred = self.pred
changes_against(pred) if pred
end
# Returns the differences between this record and an arbitrary reference
# record. The changes representation is an hash keyed by attribute whose
# values are arrays containing previous and current attributes values -
# the same format used by ActiveModel::Dirty.
#
def changes_against(ref)
self.class.attribute_names_for_history_changes.inject({}) do |changes, attr|
old = ref.public_send(attr)
new = public_send(attr)
changes.tap do |c|
changed =
if old.respond_to?(:history_eql?)
!old.history_eql?(new)
else
old != new
end
c[attr] = [old, new] if changed
end
end
end
end
end