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
186
187
188
189
# 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('lower(validity)') }

    # 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

      def update_columns(*)
        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).chronological.first
    end

    # Returns the last history entry
    #
    def last
      self.class.where(:id => rid).chronological.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


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

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.


223
224
225
# File 'lib/chrono_model/time_machine.rb', line 223

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.


230
231
232
# File 'lib/chrono_model/time_machine.rb', line 230

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.


331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/chrono_model/time_machine.rb', line 331

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


314
315
316
# File 'lib/chrono_model/time_machine.rb', line 314

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

#destroyObject

Inhibit destroy of historical records

Raises:

  • (ActiveRecord::ReadOnlyRecord)

264
265
266
267
# File 'lib/chrono_model/time_machine.rb', line 264

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)

258
259
260
# File 'lib/chrono_model/time_machine.rb', line 258

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

#historyObject

Return the complete read-only history of this instance.


245
246
247
# File 'lib/chrono_model/time_machine.rb', line 245

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.


321
322
323
324
# File 'lib/chrono_model/time_machine.rb', line 321

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.


272
273
274
275
276
277
278
279
# File 'lib/chrono_model/time_machine.rb', line 272

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(%[ LOWER(#{options[:table] || self.class.quoted_table_name}."validity") DESC ]).find(options[:id] || id)
  end
end

#pred_timestamp(options = {}) ⇒ Object

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


284
285
286
287
288
289
290
291
# File 'lib/chrono_model/time_machine.rb', line 284

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.


295
296
297
298
299
300
# File 'lib/chrono_model/time_machine.rb', line 295

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

#succ_timestamp(options = {}) ⇒ Object

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


305
306
307
308
309
310
# File 'lib/chrono_model/time_machine.rb', line 305

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.


252
253
254
# File 'lib/chrono_model/time_machine.rb', line 252

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