Module: ChronoModel::TimeMachine

Extended by:
ActiveSupport::Concern
Includes:
Patches::AsOfTimeHolder
Defined in:
lib/chrono_model/time_machine.rb

Defined Under Namespace

Modules: ClassMethods, HistoryMethods, TimeQuery

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Patches::AsOfTimeHolder

#as_of_time, #as_of_time!

Class Method Details

.chrono_modelsObject

Returns an Hash keyed by table name of ChronoModels



47
48
49
# File 'lib/chrono_model/time_machine.rb', line 47

def self.chrono_models
  (@chrono_models ||= {})
end

.define_history_model_for(model) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/chrono_model/time_machine.rb', line 51

def self.define_history_model_for(model)
  history = Class.new(model) do
    self.table_name = [Adapter::HISTORY_SCHEMA, model.table_name].join('.')

    extend TimeMachine::HistoryMethods

    scope :chronological, -> { order(:recorded_at, :hid) }

    # The history id is `hid`, but this cannot set as primary key
    # or temporal assocations will break. Solutions are welcome.
    def id
      hid
    end

    # Referenced record ID.
    #
    def rid
      attributes[self.class.primary_key]
    end

    # HACK. find() and save() require the real history ID. So we are
    # setting it now and ensuring to reset it to the original one after
    # execution completes.
    #
    def self.with_hid_pkey(&block)
      old = self.primary_key
      self.primary_key = :hid

      block.call
    ensure
      self.primary_key = old
    end

    def self.find(*)
      with_hid_pkey { super }
    end

    if RUBY_VERSION.to_f < 2.0
      # PLEASE UPDATE YOUR RUBY <3
      #
      def save_with_pkey(*)
        self.class.with_hid_pkey { save_without_pkey }
      end

      def save_with_pkey!(*)
        self.class.with_hid_pkey { save_without_pkey! }
      end

      alias_method_chain :save, :pkey
    else
      def save(*)
        self.class.with_hid_pkey { super }
      end

      def save!(*)
        self.class.with_hid_pkey { super }
      end
    end

    # Returns the previous history entry, or nil if this
    # is the first one.
    #
    def pred
      return if self.valid_from.nil?

      if self.class.timeline_associations.empty?
        self.class.where('id = ? AND upper(validity) = ?', rid, valid_from).first
      else
        super(:id => rid, :before => valid_from, :table => self.class.superclass.quoted_table_name)
      end
    end

    # Returns the next history entry, or nil if this is the
    # last one.
    #
    def succ
      return if self.valid_to.nil?

      if self.class.timeline_associations.empty?
        self.class.where('id = ? AND lower(validity) = ?', rid, valid_to).first
      else
        super(:id => rid, :after => valid_to, :table => self.class.superclass.quoted_table_name)
      end
    end
    alias :next :succ

    # Returns the first history entry
    #
    def first
      self.class.where(:id => rid).order('lower(validity)').first
    end

    # Returns the last history entry
    #
    def last
      self.class.where(:id => rid).order('lower(validity)').last
    end

    # Returns this history entry's current record
    #
    def current_version
      self.class.non_history_superclass.find(rid)
    end

    def record #:nodoc:
      ActiveSupport::Deprecation.warn '.record is deprecated in favour of .current_version'
      self.current_version
    end

    def valid_from
      validity.first
    end

    def valid_to
      validity.last
    end
    alias as_of_time valid_to

    def recorded_at
      Conversions.string_to_utc_time attributes_before_type_cast['recorded_at']
    end
  end

  model.singleton_class.instance_eval do
    define_method(:history) { history }
  end

  history.singleton_class.instance_eval do
    define_method(:sti_name) { model.sti_name }
  end

  model.const_set :History, history

  return history
end

.define_inherited_history_model_for(subclass) ⇒ Object



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/chrono_model/time_machine.rb', line 187

def self.define_inherited_history_model_for(subclass)
  # Define history model for the subclass
  history = Class.new(subclass.superclass.history)
  history.table_name = subclass.superclass.history.table_name

  # Override the STI name on the history subclass
  history.singleton_class.instance_eval do
    define_method(:sti_name) { subclass.sti_name }
  end

  # Return the subclass history via the .history method
  subclass.singleton_class.instance_eval do
    define_method(:history) { history }
  end

  # Define the History constant inside the subclass
  subclass.const_set :History, history

  history.instance_eval do
    # Monkey patch of ActiveRecord::Inheritance.
    # STI fails when a Foo::History record has Foo as type in the
    # inheritance column; AR expects the type to be an instance of the
    # current class or a descendant (or self).
    def find_sti_class(type_name)
      super(type_name + "::History")
    end
  end
end

Instance Method Details

#as_of(time) ⇒ Object

Returns a read-only representation of this record as it was time ago. Returns nil if no record is found.



219
220
221
# File 'lib/chrono_model/time_machine.rb', line 219

def as_of(time)
  _as_of(time).first
end

#as_of!(time) ⇒ Object

Returns a read-only representation of this record as it was time ago. Raises ActiveRecord::RecordNotFound if no record is found.



226
227
228
# File 'lib/chrono_model/time_machine.rb', line 226

def as_of!(time)
  _as_of(time).first!
end

#changes_against(ref) ⇒ Object

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.



327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/chrono_model/time_machine.rb', line 327

def changes_against(ref)
  self.class.attribute_names_for_history_changes.inject({}) do |changes, attr|
    old, new = ref.public_send(attr), self.public_send(attr)

    changes.tap do |c|
      changed = old.respond_to?(:history_eql?) ?
        !old.history_eql?(new) : old != new

      c[attr] = [old, new] if changed
    end
  end
end

#current_versionObject

Returns the current history version



310
311
312
# File 'lib/chrono_model/time_machine.rb', line 310

def current_version
  self.historical? ? self.class.find(self.id) : self
end

#destroyObject

Inhibit destroy of historical records

Raises:

  • (ActiveRecord::ReadOnlyRecord)


260
261
262
263
# File 'lib/chrono_model/time_machine.rb', line 260

def destroy
  raise ActiveRecord::ReadOnlyRecord, 'Cannot delete historical records' if historical?
  super
end

#historical?Boolean

Returns a boolean indicating whether this record is an history entry.

Returns:

  • (Boolean)


254
255
256
# File 'lib/chrono_model/time_machine.rb', line 254

def historical?
  self.as_of_time.present? || self.kind_of?(self.class.history)
end

#historyObject

Return the complete read-only history of this instance.



241
242
243
# File 'lib/chrono_model/time_machine.rb', line 241

def history
  self.class.history.chronological.of(self)
end

#last_changesObject

Returns the differences between this entry and the previous history one. See: changes_against.



317
318
319
320
# File 'lib/chrono_model/time_machine.rb', line 317

def last_changes
  pred = self.pred
  changes_against(pred) if pred
end

#pred(options = {}) ⇒ Object

Returns the previous record in the history, or nil if this is the only recorded entry.



268
269
270
271
272
273
274
275
# File 'lib/chrono_model/time_machine.rb', line 268

def pred(options = {})
  if self.class.timeline_associations.empty?
    history.order('upper(validity) DESC').offset(1).first
  else
    return nil unless (ts = pred_timestamp(options))
    self.class.as_of(ts).order(%[ #{options[:table] || self.class.quoted_table_name}."hid" DESC ]).find(options[:id] || id)
  end
end

#pred_timestamp(options = {}) ⇒ Object

Returns the previous timestamp in this record’s timeline. Includes temporal associations.



280
281
282
283
284
285
286
287
# File 'lib/chrono_model/time_machine.rb', line 280

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

#succ(options = {}) ⇒ Object

Returns the next record in the history timeline.



291
292
293
294
295
296
# File 'lib/chrono_model/time_machine.rb', line 291

def succ(options = {})
  unless self.class.timeline_associations.empty?
    return nil unless (ts = succ_timestamp(options))
    self.class.as_of(ts).order(%[ #{options[:table] || self.class.quoted_table_name}."hid" DESC ]).find(options[:id] || id)
  end
end

#succ_timestamp(options = {}) ⇒ Object

Returns the next timestamp in this record’s timeline. Includes temporal associations.



301
302
303
304
305
306
# File 'lib/chrono_model/time_machine.rb', line 301

def succ_timestamp(options = {})
  return nil unless historical?

  options[:after] ||= as_of_time
  timeline(options.merge(:limit => 1, :reverse => false)).first
end

#timeline(options = {}) ⇒ Object

Returns an Array of timestamps for which this instance has an history record. Takes temporal associations into account.



248
249
250
# File 'lib/chrono_model/time_machine.rb', line 248

def timeline(options = {})
  self.class.history.timeline(self, options)
end