-
Notifications
You must be signed in to change notification settings - Fork 21
/
time_machine.rb
244 lines (203 loc) · 6.87 KB
/
time_machine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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
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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# frozen_string_literal: true
require_relative 'time_machine/time_query'
require_relative 'time_machine/timeline'
require_relative '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