chrono_model 0.4.0 → 0.5.0.beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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