chrono_model 0.4.0 → 0.5.0.beta
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile.lock +1 -1
- data/README.md +64 -35
- data/README.sql +73 -27
- data/lib/chrono_model/adapter.rb +235 -44
- data/lib/chrono_model/patches.rb +28 -75
- data/lib/chrono_model/railtie.rb +0 -25
- data/lib/chrono_model/time_gate.rb +36 -0
- data/lib/chrono_model/time_machine.rb +345 -122
- data/lib/chrono_model/utils.rb +89 -0
- data/lib/chrono_model/version.rb +1 -1
- data/lib/chrono_model.rb +2 -7
- data/spec/adapter_spec.rb +74 -7
- data/spec/support/connection.rb +1 -1
- data/spec/support/helpers.rb +20 -1
- data/spec/time_machine_spec.rb +216 -12
- metadata +11 -9
@@ -12,12 +12,25 @@ module ChronoModel
|
|
12
12
|
end
|
13
13
|
|
14
14
|
if table_exists? && !chrono?
|
15
|
-
|
15
|
+
puts "WARNING: #{table_name} is not a temporal table. " \
|
16
16
|
"Please use change_table :#{table_name}, :temporal => true"
|
17
17
|
end
|
18
18
|
|
19
19
|
history = TimeMachine.define_history_model_for(self)
|
20
20
|
TimeMachine.chrono_models[table_name] = history
|
21
|
+
|
22
|
+
# STI support. TODO: more thorough testing
|
23
|
+
#
|
24
|
+
def self.inherited(subclass)
|
25
|
+
super
|
26
|
+
|
27
|
+
# Do not smash stack: as the below method is defining a
|
28
|
+
# new anonymous class, without this check this leads to
|
29
|
+
# infinite recursion.
|
30
|
+
unless subclass.name.nil?
|
31
|
+
TimeMachine.define_inherited_history_model_for(subclass)
|
32
|
+
end
|
33
|
+
end
|
21
34
|
end
|
22
35
|
|
23
36
|
# Returns an Hash keyed by table name of ChronoModels
|
@@ -55,63 +68,84 @@ module ChronoModel
|
|
55
68
|
self.primary_key = old
|
56
69
|
end
|
57
70
|
|
58
|
-
#
|
71
|
+
# Returns the previous history entry, or nil if this
|
72
|
+
# is the first one.
|
59
73
|
#
|
60
|
-
def
|
61
|
-
|
74
|
+
def pred
|
75
|
+
return nil if self.valid_from.year.zero?
|
76
|
+
|
77
|
+
if self.class.timeline_associations.empty?
|
78
|
+
self.class.where(:id => rid, :valid_to => valid_from_before_type_cast).first
|
79
|
+
else
|
80
|
+
super(:id => rid, :before => valid_from)
|
81
|
+
end
|
62
82
|
end
|
63
83
|
|
64
|
-
#
|
84
|
+
# Returns the next history entry, or nil if this is the
|
85
|
+
# last one.
|
65
86
|
#
|
66
|
-
def
|
67
|
-
|
87
|
+
def succ
|
88
|
+
return nil if self.valid_to.year == 9999
|
89
|
+
|
90
|
+
if self.class.timeline_associations.empty?
|
91
|
+
self.class.where(:id => rid, :valid_from => valid_to_before_type_cast).first
|
92
|
+
else
|
93
|
+
super(:id => rid, :after => valid_to)
|
94
|
+
end
|
68
95
|
end
|
96
|
+
alias :next :succ
|
69
97
|
|
70
|
-
#
|
98
|
+
# Returns the first history entry
|
71
99
|
#
|
72
|
-
def
|
73
|
-
|
100
|
+
def first
|
101
|
+
self.class.where(:id => rid).order(:valid_from).first
|
74
102
|
end
|
75
103
|
|
76
|
-
#
|
77
|
-
# current timestamp in association queries
|
104
|
+
# Returns the last history entry
|
78
105
|
#
|
79
|
-
def
|
80
|
-
|
106
|
+
def last
|
107
|
+
self.class.where(:id => rid).order(:valid_from).last
|
81
108
|
end
|
82
109
|
|
83
|
-
#
|
110
|
+
# Returns this history entry's current record
|
84
111
|
#
|
85
|
-
def
|
86
|
-
|
112
|
+
def record
|
113
|
+
self.class.superclass.find(rid)
|
87
114
|
end
|
88
|
-
|
89
|
-
private
|
90
|
-
# Hack around AR timezone support. These timestamps are recorded
|
91
|
-
# by the chrono rewrite rules in UTC, but AR reads them as they
|
92
|
-
# were stored in the local timezone - thus here we reset its
|
93
|
-
# assumption. TODO: OPTIMIZE.
|
94
|
-
#
|
95
|
-
if ActiveRecord::Base.default_timezone != :utc
|
96
|
-
def utc_timestamp_from(attr)
|
97
|
-
attributes[attr].utc + Time.now.utc_offset
|
98
|
-
end
|
99
|
-
else
|
100
|
-
def utc_timestamp_from(attr)
|
101
|
-
attributes[attr]
|
102
|
-
end
|
103
|
-
end
|
104
115
|
end
|
105
116
|
|
106
117
|
model.singleton_class.instance_eval do
|
107
118
|
define_method(:history) { history }
|
108
119
|
end
|
109
120
|
|
121
|
+
history.singleton_class.instance_eval do
|
122
|
+
define_method(:sti_name) { model.sti_name }
|
123
|
+
end
|
124
|
+
|
110
125
|
model.const_set :History, history
|
111
126
|
|
112
127
|
return history
|
113
128
|
end
|
114
129
|
|
130
|
+
def self.define_inherited_history_model_for(subclass)
|
131
|
+
# Define history model for the subclass
|
132
|
+
history = Class.new(subclass.superclass.history)
|
133
|
+
history.table_name = subclass.superclass.history.table_name
|
134
|
+
|
135
|
+
# Override the STI name on the history subclass
|
136
|
+
history.singleton_class.instance_eval do
|
137
|
+
define_method(:sti_name) { subclass.sti_name }
|
138
|
+
end
|
139
|
+
|
140
|
+
# Return the subclass history via the .history method
|
141
|
+
subclass.singleton_class.instance_eval do
|
142
|
+
define_method(:history) { history }
|
143
|
+
end
|
144
|
+
|
145
|
+
# Define the History constant inside the subclass
|
146
|
+
subclass.const_set :History, history
|
147
|
+
end
|
148
|
+
|
115
149
|
# Returns a read-only representation of this record as it was +time+ ago.
|
116
150
|
#
|
117
151
|
def as_of(time)
|
@@ -127,18 +161,108 @@ module ChronoModel
|
|
127
161
|
# Returns an Array of timestamps for which this instance has an history
|
128
162
|
# record. Takes temporal associations into account.
|
129
163
|
#
|
130
|
-
def
|
131
|
-
self.class.history.
|
132
|
-
query.where(:id => self)
|
133
|
-
end
|
164
|
+
def timeline(options = {})
|
165
|
+
self.class.history.timeline(self, options)
|
134
166
|
end
|
135
167
|
|
168
|
+
# Returns a boolean indicating whether this record is an history entry.
|
169
|
+
#
|
136
170
|
def historical?
|
137
|
-
self.
|
171
|
+
self.attributes.key?('as_of_time') || self.kind_of?(self.class.history)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Hack around AR timezone support. These timestamps are recorded
|
175
|
+
# by the chrono rewrite rules in UTC, but AR reads them as they
|
176
|
+
# were stored in the local timezone - thus here we bypass type
|
177
|
+
# casting to force creation of UTC timestamps.
|
178
|
+
#
|
179
|
+
%w( valid_from valid_to recorded_at as_of_time ).each do |attr|
|
180
|
+
define_method(attr) do
|
181
|
+
Conversions.string_to_utc_time attributes_before_type_cast[attr]
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Inhibit destroy of historical records
|
186
|
+
#
|
187
|
+
def destroy
|
188
|
+
raise ActiveRecord::ReadOnlyRecord, 'Cannot delete historical records' if historical?
|
189
|
+
super
|
138
190
|
end
|
139
191
|
|
140
|
-
|
141
|
-
|
192
|
+
# Returns the previous record in the history, or nil if this is the only
|
193
|
+
# recorded entry.
|
194
|
+
#
|
195
|
+
def pred(options = {})
|
196
|
+
if self.class.timeline_associations.empty?
|
197
|
+
history.order('valid_to DESC').offset(1).first
|
198
|
+
else
|
199
|
+
return nil unless (ts = pred_timestamp(options))
|
200
|
+
self.class.as_of(ts).order('hid desc').find(options[:id] || id)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Returns the previous timestamp in this record's timeline. Includes
|
205
|
+
# temporal associations.
|
206
|
+
#
|
207
|
+
def pred_timestamp(options = {})
|
208
|
+
if historical?
|
209
|
+
options[:before] ||= as_of_time
|
210
|
+
timeline(options.merge(:limit => 1, :reverse => true)).first
|
211
|
+
else
|
212
|
+
timeline(options.merge(:limit => 2, :reverse => true)).second
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# Returns the next record in the history timeline.
|
217
|
+
#
|
218
|
+
def succ(options = {})
|
219
|
+
unless self.class.timeline_associations.empty?
|
220
|
+
return nil unless (ts = succ_timestamp(options))
|
221
|
+
self.class.as_of(ts).order('hid desc').find(options[:id] || id)
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# Returns the next timestamp in this record's timeline. Includes temporal
|
226
|
+
# associations.
|
227
|
+
#
|
228
|
+
def succ_timestamp(options = {})
|
229
|
+
return nil unless historical?
|
230
|
+
|
231
|
+
options[:after] ||= as_of_time
|
232
|
+
timeline(options.merge(:limit => 1, :reverse => false)).first
|
233
|
+
end
|
234
|
+
|
235
|
+
# Returns the differences between this entry and the previous history one.
|
236
|
+
# See: +changes_against+.
|
237
|
+
#
|
238
|
+
def last_changes
|
239
|
+
pred = self.pred
|
240
|
+
changes_against(pred) if pred
|
241
|
+
end
|
242
|
+
|
243
|
+
# Returns the differences between this record and an arbitrary reference
|
244
|
+
# record. The changes representation is an hash keyed by attribute whose
|
245
|
+
# values are arrays containing previous and current attributes values -
|
246
|
+
# the same format used by ActiveModel::Dirty.
|
247
|
+
#
|
248
|
+
def changes_against(ref)
|
249
|
+
self.class.attribute_names_for_history_changes.inject({}) do |changes, attr|
|
250
|
+
old, new = ref.public_send(attr), self.public_send(attr)
|
251
|
+
|
252
|
+
changes.tap do |c|
|
253
|
+
changed = old.respond_to?(:history_eql?) ?
|
254
|
+
!old.history_eql?(new) : old != new
|
255
|
+
|
256
|
+
c[attr] = [old, new] if changed
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Wraps AR::Base#attributes by removing the __xid internal attribute
|
262
|
+
# used to squash together changes made in the same transaction.
|
263
|
+
#
|
264
|
+
%w( attributes attribute_names ).each do |name|
|
265
|
+
define_method(name) { super().tap {|x| x.delete('__xid')} }
|
142
266
|
end
|
143
267
|
|
144
268
|
module ClassMethods
|
@@ -147,30 +271,92 @@ module ChronoModel
|
|
147
271
|
def as_of(time)
|
148
272
|
history.as_of(time, current_scope)
|
149
273
|
end
|
274
|
+
|
275
|
+
def attribute_names_for_history_changes
|
276
|
+
@attribute_names_for_history_changes ||= attribute_names -
|
277
|
+
%w( id hid valid_from valid_to recorded_at as_of_time )
|
278
|
+
end
|
279
|
+
|
280
|
+
def has_timeline(options)
|
281
|
+
changes = options.delete(:changes)
|
282
|
+
assocs = history.has_timeline(options)
|
283
|
+
|
284
|
+
attributes = changes.present? ?
|
285
|
+
Array.wrap(changes) : assocs.map(&:name)
|
286
|
+
|
287
|
+
attribute_names_for_history_changes.concat(attributes.map(&:to_s))
|
288
|
+
end
|
289
|
+
|
290
|
+
delegate :timeline_associations, :to => :history
|
291
|
+
end
|
292
|
+
|
293
|
+
module TimeQuery
|
294
|
+
OPERATORS = {
|
295
|
+
:at => '&&',
|
296
|
+
:before => '<<',
|
297
|
+
:after => '>>',
|
298
|
+
}.freeze
|
299
|
+
|
300
|
+
def time_query(match, time_or_times, options)
|
301
|
+
from_f, to_f = options[:on]
|
302
|
+
|
303
|
+
from_t, to_t = if time_or_times.kind_of?(Array)
|
304
|
+
time_or_times.map! {|t| time_for_time_query(t)}
|
305
|
+
else
|
306
|
+
[time_for_time_query(time_or_times)]*2
|
307
|
+
end
|
308
|
+
|
309
|
+
if match == :not
|
310
|
+
where(%[
|
311
|
+
#{build_time_query(:before, from_t, from_f, to_t, to_f)} OR
|
312
|
+
#{build_time_query(:after, from_t, from_f, to_t, to_f)}
|
313
|
+
])
|
314
|
+
else
|
315
|
+
where(build_time_query(match, from_t, from_f, to_t, to_f))
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
private
|
320
|
+
def time_for_time_query(t)
|
321
|
+
t = Conversions.time_to_utc_string(t.utc) if t.kind_of?(Time)
|
322
|
+
t == :now ? 'now()' : "#{connection.quote(t)}::timestamp"
|
323
|
+
end
|
324
|
+
|
325
|
+
def build_time_query(match, from_t, from_f, to_t, to_f)
|
326
|
+
%[
|
327
|
+
box(
|
328
|
+
point( date_part( 'epoch', #{from_f} ), 0 ),
|
329
|
+
point( date_part( 'epoch', #{to_f } ), 0 )
|
330
|
+
) #{OPERATORS.fetch(match)}
|
331
|
+
box(
|
332
|
+
point( date_part( 'epoch', #{from_t} ), 0 ),
|
333
|
+
point( date_part( 'epoch', #{to_t } ), 0 )
|
334
|
+
)
|
335
|
+
]
|
336
|
+
end
|
150
337
|
end
|
151
338
|
|
152
339
|
# Methods that make up the history interface of the companion History
|
153
|
-
# model
|
340
|
+
# model, automatically built for each Model that includes TimeMachine
|
154
341
|
module HistoryMethods
|
342
|
+
include TimeQuery
|
343
|
+
|
155
344
|
# Fetches as of +time+ records.
|
156
345
|
#
|
157
346
|
def as_of(time, scope = nil)
|
158
|
-
|
159
|
-
|
160
|
-
as_of = superclass.unscoped.readonly.
|
161
|
-
with(superclass.table_name, at(time))
|
347
|
+
as_of = superclass.unscoped.readonly.from(virtual_table_at(time))
|
162
348
|
|
163
349
|
# Add default scopes back if we're passed nil or a
|
164
350
|
# specific scope, because we're .unscopeing above.
|
165
351
|
#
|
166
|
-
scopes = scope.
|
352
|
+
scopes = !scope.nil? ? [scope] : (
|
167
353
|
superclass.default_scopes.map do |s|
|
168
354
|
s.respond_to?(:call) ? s.call : s
|
169
355
|
end)
|
170
356
|
|
171
|
-
scopes.each do |
|
172
|
-
|
173
|
-
|
357
|
+
scopes.each do |s|
|
358
|
+
s.order_values.each {|clause| as_of = as_of.order(clause)}
|
359
|
+
s.where_values.each {|clause| as_of = as_of.where(clause)}
|
174
360
|
end
|
175
361
|
|
176
362
|
as_of.instance_variable_set(:@temporal, time)
|
@@ -178,60 +364,133 @@ module ChronoModel
|
|
178
364
|
return as_of
|
179
365
|
end
|
180
366
|
|
367
|
+
def virtual_table_at(time, name = nil)
|
368
|
+
name = name ? connection.quote_table_name(name) :
|
369
|
+
superclass.quoted_table_name
|
370
|
+
|
371
|
+
"(#{at(time).to_sql}) #{name}"
|
372
|
+
end
|
373
|
+
|
181
374
|
# Fetches history record at the given time
|
182
375
|
#
|
183
376
|
def at(time)
|
184
|
-
|
377
|
+
time = Conversions.time_to_utc_string(time.utc) if time.kind_of?(Time)
|
378
|
+
|
185
379
|
unscoped.
|
186
|
-
select("#{quoted_table_name}.*,
|
187
|
-
|
380
|
+
select("#{quoted_table_name}.*, #{connection.quote(time)} AS as_of_time").
|
381
|
+
time_query(:at, time, :on => quoted_history_fields)
|
188
382
|
end
|
189
383
|
|
190
384
|
# Returns the whole history as read only.
|
191
385
|
#
|
192
386
|
def all
|
193
387
|
readonly.
|
194
|
-
order("#{
|
388
|
+
order("#{quoted_table_name}.recorded_at, hid").all
|
195
389
|
end
|
196
390
|
|
197
|
-
# Fetches the given +object+ history, sorted by history record time
|
391
|
+
# Fetches the given +object+ history, sorted by history record time
|
392
|
+
# by default. Always includes an "as_of_time" column that is either
|
393
|
+
# the valid_to timestamp or now() if history validity is maximum.
|
198
394
|
#
|
199
395
|
def of(object)
|
200
|
-
|
201
|
-
readonly.
|
202
|
-
select("#{table_name}.*, #{now} AS as_of_time").
|
203
|
-
order("#{table_name}.recorded_at, hid").
|
204
|
-
where(:id => object)
|
396
|
+
readonly.where(:id => object).extend(HistorySelect)
|
205
397
|
end
|
206
398
|
|
399
|
+
module HistorySelect #:nodoc:
|
400
|
+
Aggregates = %r{(?:(?:bit|bool)_(?:and|or)|(?:array_|string_|xml)agg|count|every|m(?:in|ax)|sum|stddev|var(?:_pop|_samp|iance)|corr|covar_|regr_)\w*\s*\(}i
|
207
401
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
402
|
+
def build_arel
|
403
|
+
has_aggregate = select_values.any? do |v|
|
404
|
+
v.kind_of?(Arel::Nodes::Function) || # FIXME this is a bit ugly.
|
405
|
+
v.to_s =~ Aggregates
|
406
|
+
end
|
407
|
+
|
408
|
+
return super if has_aggregate
|
409
|
+
|
410
|
+
if order_values.blank?
|
411
|
+
self.order_values += ["#{quoted_table_name}.recorded_at, #{quoted_table_name}.hid"]
|
412
|
+
end
|
413
|
+
|
414
|
+
super.tap do |rel|
|
415
|
+
rel.project("LEAST(valid_to, now()::timestamp) AS as_of_time")
|
416
|
+
end
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
include(Timeline = Module.new do
|
421
|
+
# Returns an Array of unique UTC timestamps for which at least an
|
422
|
+
# history record exists. Takes temporal associations into account.
|
423
|
+
#
|
424
|
+
def timeline(record = nil, options = {})
|
425
|
+
rid = record.respond_to?(:rid) ? record.rid : record.id if record
|
426
|
+
|
427
|
+
assocs = options.key?(:with) ?
|
428
|
+
timeline_associations_from(options[:with]) : timeline_associations
|
429
|
+
|
430
|
+
models = []
|
431
|
+
models.push self if self.chrono?
|
432
|
+
models.concat(assocs.map {|a| a.klass.history})
|
433
|
+
|
434
|
+
return [] if models.empty?
|
435
|
+
|
436
|
+
fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
|
437
|
+
fields.map! {|f| "#{f} + INTERVAL '2 usec'"}
|
215
438
|
|
216
|
-
|
217
|
-
|
439
|
+
relation = self.
|
440
|
+
joins(*assocs.map(&:name)).
|
441
|
+
select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts").
|
442
|
+
order('ts ' << (options[:reverse] ? 'DESC' : 'ASC'))
|
218
443
|
|
219
|
-
|
220
|
-
|
221
|
-
select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts").
|
222
|
-
order('ts')
|
444
|
+
relation = relation.from(%["public".#{quoted_table_name}]) unless self.chrono?
|
445
|
+
relation = relation.where(:id => rid) if rid
|
223
446
|
|
224
|
-
|
447
|
+
sql = "SELECT ts FROM ( #{relation.to_sql} ) foo WHERE ts IS NOT NULL"
|
225
448
|
|
226
|
-
|
227
|
-
|
449
|
+
if options.key?(:before)
|
450
|
+
sql << " AND ts < '#{Conversions.time_to_utc_string(options[:before])}'"
|
451
|
+
end
|
452
|
+
|
453
|
+
if options.key?(:after)
|
454
|
+
sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after ])}'"
|
455
|
+
end
|
228
456
|
|
229
|
-
|
230
|
-
|
231
|
-
|
457
|
+
if rid
|
458
|
+
sql << (self.chrono? ? %[
|
459
|
+
AND ts >= ( SELECT MIN(valid_from) FROM #{quoted_table_name} WHERE id = #{rid} )
|
460
|
+
AND ts < ( SELECT MAX(valid_to ) FROM #{quoted_table_name} WHERE id = #{rid} )
|
461
|
+
] : %[ AND ts < NOW() ])
|
462
|
+
end
|
463
|
+
|
464
|
+
sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
|
465
|
+
|
466
|
+
sql.gsub! 'INNER JOIN', 'LEFT OUTER JOIN'
|
467
|
+
|
468
|
+
connection.on_schema(Adapter::HISTORY_SCHEMA) do
|
469
|
+
connection.select_values(sql, "#{self.name} periods").map! do |ts|
|
470
|
+
Conversions.string_to_utc_time ts
|
471
|
+
end
|
232
472
|
end
|
233
473
|
end
|
234
|
-
|
474
|
+
|
475
|
+
def has_timeline(options)
|
476
|
+
options.assert_valid_keys(:with)
|
477
|
+
|
478
|
+
timeline_associations_from(options[:with]).tap do |assocs|
|
479
|
+
timeline_associations.concat assocs
|
480
|
+
end
|
481
|
+
end
|
482
|
+
|
483
|
+
def timeline_associations
|
484
|
+
@timeline_associations ||= []
|
485
|
+
end
|
486
|
+
|
487
|
+
def timeline_associations_from(names)
|
488
|
+
Array.wrap(names).map do |name|
|
489
|
+
reflect_on_association(name) or raise ArgumentError,
|
490
|
+
"No association found for name `#{name}'"
|
491
|
+
end
|
492
|
+
end
|
493
|
+
end)
|
235
494
|
|
236
495
|
def quoted_history_fields
|
237
496
|
@quoted_history_fields ||= [:valid_from, :valid_to].map do |field|
|
@@ -246,10 +505,13 @@ module ChronoModel
|
|
246
505
|
def build_arel
|
247
506
|
super.tap do |arel|
|
248
507
|
|
249
|
-
|
250
|
-
|
251
|
-
next unless
|
252
|
-
|
508
|
+
arel.join_sources.each do |join|
|
509
|
+
model = TimeMachine.chrono_models[join.left.table_name]
|
510
|
+
next unless model
|
511
|
+
|
512
|
+
join.left = Arel::Nodes::SqlLiteral.new(
|
513
|
+
model.history.virtual_table_at(@temporal, join.left.table_alias || join.left.table_name)
|
514
|
+
)
|
253
515
|
end if @temporal
|
254
516
|
|
255
517
|
end
|
@@ -257,45 +519,6 @@ module ChronoModel
|
|
257
519
|
end
|
258
520
|
ActiveRecord::Relation.instance_eval { include QueryMethods }
|
259
521
|
|
260
|
-
module Conversions
|
261
|
-
extend self
|
262
|
-
|
263
|
-
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(\.\d+)?\z/
|
264
|
-
|
265
|
-
def string_to_utc_time(string)
|
266
|
-
if string =~ ISO_DATETIME
|
267
|
-
microsec = ($7.to_f * 1_000_000).to_i
|
268
|
-
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec
|
269
|
-
end
|
270
|
-
end
|
271
|
-
|
272
|
-
def time_to_utc_string(time)
|
273
|
-
[time.to_s(:db), sprintf('%06d', time.usec)].join '.'
|
274
|
-
end
|
275
|
-
end
|
276
|
-
|
277
|
-
module Utilities
|
278
|
-
# Amends the given history item setting a different period.
|
279
|
-
# Useful when migrating from legacy systems, but it is here
|
280
|
-
# as this is not a proper API.
|
281
|
-
#
|
282
|
-
# Extend your model with the Utilities model if you want to
|
283
|
-
# use it.
|
284
|
-
#
|
285
|
-
def amend_history_period!(hid, from, to)
|
286
|
-
unless [from, to].all? {|ts| ts.respond_to?(:zone) && ts.zone == 'UTC'}
|
287
|
-
raise 'Can amend history only with UTC timestamps'
|
288
|
-
end
|
289
|
-
|
290
|
-
connection.execute %[
|
291
|
-
UPDATE #{history_table_name}
|
292
|
-
SET valid_from = #{connection.quote(from)},
|
293
|
-
valid_to = #{connection.quote(to )}
|
294
|
-
WHERE hid = #{hid.to_i}
|
295
|
-
]
|
296
|
-
end
|
297
|
-
end
|
298
|
-
|
299
522
|
end
|
300
523
|
|
301
524
|
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
module Conversions
|
4
|
+
extend self
|
5
|
+
|
6
|
+
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(\.\d+)?\z/
|
7
|
+
|
8
|
+
def string_to_utc_time(string)
|
9
|
+
if string =~ ISO_DATETIME
|
10
|
+
microsec = ($7.to_f * 1_000_000).to_i
|
11
|
+
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, microsec
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def time_to_utc_string(time)
|
16
|
+
[time.to_s(:db), sprintf('%06d', time.usec)].join '.'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
module Utilities
|
21
|
+
# Amends the given history item setting a different period.
|
22
|
+
# Useful when migrating from legacy systems, but it is here
|
23
|
+
# as this is not a proper API.
|
24
|
+
#
|
25
|
+
# Extend your model with the Utilities model if you want to
|
26
|
+
# use it.
|
27
|
+
#
|
28
|
+
def amend_period!(hid, from, to)
|
29
|
+
unless [from, to].all? {|ts| ts.respond_to?(:zone) && ts.zone == 'UTC'}
|
30
|
+
raise 'Can amend history only with UTC timestamps'
|
31
|
+
end
|
32
|
+
|
33
|
+
connection.execute %[
|
34
|
+
UPDATE #{quoted_table_name}
|
35
|
+
SET "valid_from" = #{connection.quote(from)},
|
36
|
+
"valid_to" = #{connection.quote(to )}
|
37
|
+
WHERE "hid" = #{hid.to_i}
|
38
|
+
]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module Migrate
|
43
|
+
extend self
|
44
|
+
|
45
|
+
def upgrade_indexes!(base = ActiveRecord::Base)
|
46
|
+
use base
|
47
|
+
|
48
|
+
db.on_schema(Adapter::HISTORY_SCHEMA) do
|
49
|
+
db.tables.each do |table|
|
50
|
+
if db.is_chrono?(table)
|
51
|
+
upgrade_indexes_for(table)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
attr_reader :db
|
59
|
+
|
60
|
+
def use(ar)
|
61
|
+
@db = ar.connection
|
62
|
+
end
|
63
|
+
|
64
|
+
def upgrade_indexes_for(table_name)
|
65
|
+
upgradeable =
|
66
|
+
%r{_snapshot$|_valid_(?:from|to)$|_recorded_at$|_instance_(?:update|history)$}
|
67
|
+
|
68
|
+
indexes_sql = %[
|
69
|
+
SELECT DISTINCT i.relname
|
70
|
+
FROM pg_class t
|
71
|
+
INNER JOIN pg_index d ON t.oid = d.indrelid
|
72
|
+
INNER JOIN pg_class i ON d.indexrelid = i.oid
|
73
|
+
WHERE i.relkind = 'i'
|
74
|
+
AND d.indisprimary = 'f'
|
75
|
+
AND t.relname = '#{table_name}'
|
76
|
+
AND i.relnamespace IN (SELECT oid FROM pg_namespace WHERE nspname = ANY(current_schemas(false)) )
|
77
|
+
]
|
78
|
+
|
79
|
+
db.select_values(indexes_sql).each do |idx|
|
80
|
+
if idx =~ upgradeable
|
81
|
+
db.execute "DROP INDEX #{idx}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
db.send(:chrono_create_history_indexes_for, table_name)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
data/lib/chrono_model/version.rb
CHANGED