chrono_model 0.5.3 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +1 -1
- data/.travis.yml +7 -0
- data/Gemfile +10 -1
- data/LICENSE +3 -1
- data/README.md +239 -136
- data/README.sql +108 -94
- data/chrono_model.gemspec +5 -4
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +42 -0
- data/lib/chrono_model.rb +0 -8
- data/lib/chrono_model/adapter.rb +346 -212
- data/lib/chrono_model/patches.rb +21 -8
- data/lib/chrono_model/railtie.rb +1 -13
- data/lib/chrono_model/time_gate.rb +2 -2
- data/lib/chrono_model/time_machine.rb +153 -87
- data/lib/chrono_model/utils.rb +35 -8
- data/lib/chrono_model/version.rb +1 -1
- data/spec/adapter_spec.rb +154 -14
- data/spec/config.yml.example +1 -0
- data/spec/json_ops_spec.rb +48 -0
- data/spec/support/connection.rb +4 -9
- data/spec/support/helpers.rb +27 -2
- data/spec/support/matchers/column.rb +5 -2
- data/spec/support/matchers/index.rb +4 -0
- data/spec/support/matchers/schema.rb +4 -0
- data/spec/support/matchers/table.rb +94 -21
- data/spec/time_machine_spec.rb +62 -28
- data/spec/time_query_spec.rb +227 -0
- data/sql/json_ops.sql +56 -0
- data/sql/uninstall-json_ops.sql +24 -0
- metadata +44 -18
- data/lib/chrono_model/compatibility.rb +0 -31
data/lib/chrono_model/patches.rb
CHANGED
@@ -14,11 +14,11 @@ module ChronoModel
|
|
14
14
|
class Association < ActiveRecord::Associations::Association
|
15
15
|
|
16
16
|
# If the association class or the through association are ChronoModels,
|
17
|
-
# then fetches the records from a virtual table using a subquery
|
17
|
+
# then fetches the records from a virtual table using a subquery scope
|
18
18
|
# to a specific timestamp.
|
19
|
-
def
|
20
|
-
|
21
|
-
return
|
19
|
+
def scope
|
20
|
+
scope = super
|
21
|
+
return scope unless _chrono_record?
|
22
22
|
|
23
23
|
klass = reflection.options[:polymorphic] ?
|
24
24
|
owner.public_send(reflection.foreign_type).constantize :
|
@@ -28,23 +28,36 @@ module ChronoModel
|
|
28
28
|
# For standard associations, replace the table name with the virtual
|
29
29
|
# as-of table name at the owner's as-of-time
|
30
30
|
#
|
31
|
-
|
31
|
+
scope = scope.readonly.from(klass.history.virtual_table_at(owner.as_of_time))
|
32
32
|
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
33
33
|
|
34
34
|
# For through associations, replace the joined table name instead.
|
35
35
|
#
|
36
|
-
|
36
|
+
scope.join_sources.each do |join|
|
37
37
|
if join.left.name == through_reflection.klass.table_name
|
38
38
|
v_table = through_reflection.klass.history.virtual_table_at(
|
39
39
|
owner.as_of_time, join.left.table_alias || join.left.table_name)
|
40
40
|
|
41
|
-
|
41
|
+
# avoid problems in Rails when code down the line expects the
|
42
|
+
# join.left to respond to the following methods. we modify
|
43
|
+
# the instance of SqlLiteral to do just that.
|
44
|
+
table_name = join.left.table_name
|
45
|
+
table_alias = join.left.table_alias
|
46
|
+
join.left = Arel::Nodes::SqlLiteral.new(v_table)
|
47
|
+
|
48
|
+
class << join.left
|
49
|
+
attr_accessor :name, :table_name, :table_alias
|
50
|
+
end
|
51
|
+
|
52
|
+
join.left.name = table_name
|
53
|
+
join.left.table_name = table_name
|
54
|
+
join.left.table_alias = table_alias
|
42
55
|
end
|
43
56
|
end
|
44
57
|
|
45
58
|
end
|
46
59
|
|
47
|
-
return
|
60
|
+
return scope
|
48
61
|
end
|
49
62
|
|
50
63
|
private
|
data/lib/chrono_model/railtie.rb
CHANGED
@@ -1,21 +1,9 @@
|
|
1
1
|
module ChronoModel
|
2
2
|
class Railtie < ::Rails::Railtie
|
3
|
-
|
4
|
-
ActiveRecord::Base.connection.chrono_create_schemas!
|
5
|
-
end
|
3
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task /chronomodel/, ActiveRecord::Tasks::PostgreSQLDatabaseTasks
|
6
4
|
|
7
5
|
rake_tasks do
|
8
6
|
load 'chrono_model/schema_format.rake'
|
9
|
-
|
10
|
-
namespace :db do
|
11
|
-
namespace :chrono do
|
12
|
-
task :create_schemas do
|
13
|
-
ActiveRecord::Base.connection.chrono_create_schemas!
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
17
|
-
|
18
|
-
task 'db:schema:load' => 'db:chrono:create_schemas'
|
19
7
|
end
|
20
8
|
end
|
21
9
|
end
|
@@ -11,10 +11,10 @@ module ChronoModel
|
|
11
11
|
time = Conversions.time_to_utc_string(time.utc) if time.kind_of? Time
|
12
12
|
|
13
13
|
virtual_table = select(%[
|
14
|
-
#{quoted_table_name}.*, #{connection.quote(time)} AS "as_of_time"]
|
14
|
+
#{quoted_table_name}.*, #{connection.quote(time)}::timestamp AS "as_of_time"]
|
15
15
|
).to_sql
|
16
16
|
|
17
|
-
as_of =
|
17
|
+
as_of = all.from("(#{virtual_table}) #{quoted_table_name}")
|
18
18
|
|
19
19
|
as_of.instance_variable_set(:@temporal, time)
|
20
20
|
|
@@ -6,11 +6,6 @@ module ChronoModel
|
|
6
6
|
extend ActiveSupport::Concern
|
7
7
|
|
8
8
|
included do
|
9
|
-
unless supports_chrono?
|
10
|
-
raise Error, "Your database server is not supported by ChronoModel. "\
|
11
|
-
"Currently, only PostgreSQL >= 9.0 is supported."
|
12
|
-
end
|
13
|
-
|
14
9
|
if table_exists? && !chrono?
|
15
10
|
puts "WARNING: #{table_name} is not a temporal table. " \
|
16
11
|
"Please use change_table :#{table_name}, :temporal => true"
|
@@ -51,7 +46,7 @@ module ChronoModel
|
|
51
46
|
hid
|
52
47
|
end
|
53
48
|
|
54
|
-
# Referenced record ID
|
49
|
+
# Referenced record ID.
|
55
50
|
#
|
56
51
|
def rid
|
57
52
|
attributes[self.class.primary_key]
|
@@ -72,12 +67,12 @@ module ChronoModel
|
|
72
67
|
# is the first one.
|
73
68
|
#
|
74
69
|
def pred
|
75
|
-
return
|
70
|
+
return if self.valid_from.nil?
|
76
71
|
|
77
72
|
if self.class.timeline_associations.empty?
|
78
|
-
self.class.where(
|
73
|
+
self.class.where('id = ? AND upper(validity) = ?', rid, valid_from).first
|
79
74
|
else
|
80
|
-
super(:id => rid, :before => valid_from)
|
75
|
+
super(:id => rid, :before => valid_from, :table => self.class.superclass.quoted_table_name)
|
81
76
|
end
|
82
77
|
end
|
83
78
|
|
@@ -85,12 +80,12 @@ module ChronoModel
|
|
85
80
|
# last one.
|
86
81
|
#
|
87
82
|
def succ
|
88
|
-
return
|
83
|
+
return if self.valid_to.nil?
|
89
84
|
|
90
85
|
if self.class.timeline_associations.empty?
|
91
|
-
self.class.where(
|
86
|
+
self.class.where('id = ? AND lower(validity) = ?', rid, valid_to).first
|
92
87
|
else
|
93
|
-
super(:id => rid, :after => valid_to)
|
88
|
+
super(:id => rid, :after => valid_to, :table => self.class.superclass.quoted_table_name)
|
94
89
|
end
|
95
90
|
end
|
96
91
|
alias :next :succ
|
@@ -98,13 +93,13 @@ module ChronoModel
|
|
98
93
|
# Returns the first history entry
|
99
94
|
#
|
100
95
|
def first
|
101
|
-
self.class.where(:id => rid).order(
|
96
|
+
self.class.where(:id => rid).order('lower(validity)').first
|
102
97
|
end
|
103
98
|
|
104
99
|
# Returns the last history entry
|
105
100
|
#
|
106
101
|
def last
|
107
|
-
self.class.where(:id => rid).order(
|
102
|
+
self.class.where(:id => rid).order('lower(validity)').last
|
108
103
|
end
|
109
104
|
|
110
105
|
# Returns this history entry's current record
|
@@ -112,6 +107,18 @@ module ChronoModel
|
|
112
107
|
def record
|
113
108
|
self.class.superclass.find(rid)
|
114
109
|
end
|
110
|
+
|
111
|
+
def valid_from
|
112
|
+
validity.first
|
113
|
+
end
|
114
|
+
|
115
|
+
def valid_to
|
116
|
+
validity.last
|
117
|
+
end
|
118
|
+
|
119
|
+
def recorded_at
|
120
|
+
Conversions.string_to_utc_time attributes_before_type_cast['recorded_at']
|
121
|
+
end
|
115
122
|
end
|
116
123
|
|
117
124
|
model.singleton_class.instance_eval do
|
@@ -188,15 +195,10 @@ module ChronoModel
|
|
188
195
|
self.attributes.key?('as_of_time') || self.kind_of?(self.class.history)
|
189
196
|
end
|
190
197
|
|
191
|
-
#
|
192
|
-
# by the chrono rewrite rules in UTC, but AR reads them as they
|
193
|
-
# were stored in the local timezone - thus here we bypass type
|
194
|
-
# casting to force creation of UTC timestamps.
|
198
|
+
# Read the virtual 'as_of_time' attribute and return it as an UTC timestamp.
|
195
199
|
#
|
196
|
-
|
197
|
-
|
198
|
-
Conversions.string_to_utc_time attributes_before_type_cast[attr]
|
199
|
-
end
|
200
|
+
def as_of_time
|
201
|
+
Conversions.string_to_utc_time attributes_before_type_cast['as_of_time']
|
200
202
|
end
|
201
203
|
|
202
204
|
# Inhibit destroy of historical records
|
@@ -211,10 +213,10 @@ module ChronoModel
|
|
211
213
|
#
|
212
214
|
def pred(options = {})
|
213
215
|
if self.class.timeline_associations.empty?
|
214
|
-
history.order('
|
216
|
+
history.order('upper(validity) DESC').offset(1).first
|
215
217
|
else
|
216
218
|
return nil unless (ts = pred_timestamp(options))
|
217
|
-
self.class.as_of(ts).order(
|
219
|
+
self.class.as_of(ts).order(%[ #{options[:table] || self.class.quoted_table_name}."hid" DESC ]).find(options[:id] || id)
|
218
220
|
end
|
219
221
|
end
|
220
222
|
|
@@ -235,7 +237,7 @@ module ChronoModel
|
|
235
237
|
def succ(options = {})
|
236
238
|
unless self.class.timeline_associations.empty?
|
237
239
|
return nil unless (ts = succ_timestamp(options))
|
238
|
-
self.class.as_of(ts).order(
|
240
|
+
self.class.as_of(ts).order(%[ #{options[:table] || self.class.quoted_table_name}."hid" DESC ]).find(options[:id] || id)
|
239
241
|
end
|
240
242
|
end
|
241
243
|
|
@@ -275,13 +277,6 @@ module ChronoModel
|
|
275
277
|
end
|
276
278
|
end
|
277
279
|
|
278
|
-
# Wraps AR::Base#attributes by removing the __xid internal attribute
|
279
|
-
# used to squash together changes made in the same transaction.
|
280
|
-
#
|
281
|
-
%w( attributes attribute_names ).each do |name|
|
282
|
-
define_method(name) { super().tap {|x| x.delete('__xid')} }
|
283
|
-
end
|
284
|
-
|
285
280
|
module ClassMethods
|
286
281
|
# Returns an ActiveRecord::Relation on the history of this model as
|
287
282
|
# it was +time+ ago.
|
@@ -291,7 +286,7 @@ module ChronoModel
|
|
291
286
|
|
292
287
|
def attribute_names_for_history_changes
|
293
288
|
@attribute_names_for_history_changes ||= attribute_names -
|
294
|
-
%w( id hid
|
289
|
+
%w( id hid validity recorded_at as_of_time )
|
295
290
|
end
|
296
291
|
|
297
292
|
def has_timeline(options)
|
@@ -308,48 +303,76 @@ module ChronoModel
|
|
308
303
|
end
|
309
304
|
|
310
305
|
module TimeQuery
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
:
|
315
|
-
}.freeze
|
306
|
+
# TODO Documentation
|
307
|
+
#
|
308
|
+
def time_query(match, time, options)
|
309
|
+
range = columns_hash.fetch(options[:on].to_s)
|
316
310
|
|
317
|
-
|
318
|
-
|
311
|
+
query = case match
|
312
|
+
when :at
|
313
|
+
build_time_query_at(time, range)
|
319
314
|
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
315
|
+
when :not
|
316
|
+
"NOT (#{build_time_query_at(time, range)})"
|
317
|
+
|
318
|
+
when :before
|
319
|
+
build_time_query(['NULL', time_for_time_query(time, range)], range)
|
320
|
+
|
321
|
+
when :after
|
322
|
+
build_time_query([time_for_time_query(time, range), 'NULL'], range)
|
325
323
|
|
326
|
-
if match == :not
|
327
|
-
where(%[
|
328
|
-
#{build_time_query(:before, from_t, from_f, to_t, to_f)} OR
|
329
|
-
#{build_time_query(:after, from_t, from_f, to_t, to_f)}
|
330
|
-
])
|
331
324
|
else
|
332
|
-
|
325
|
+
raise ArgumentError, "Invalid time_query: #{match}"
|
333
326
|
end
|
327
|
+
|
328
|
+
where(query)
|
334
329
|
end
|
335
330
|
|
336
331
|
private
|
337
|
-
|
338
|
-
|
339
|
-
t == :now
|
332
|
+
|
333
|
+
def time_for_time_query(t, column)
|
334
|
+
if t == :now || t == :today
|
335
|
+
now_for_column(column)
|
336
|
+
else
|
337
|
+
[connection.quote(t, column),
|
338
|
+
primitive_type_for_column(column)
|
339
|
+
].join('::')
|
340
|
+
end
|
340
341
|
end
|
341
342
|
|
342
|
-
def
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
343
|
+
def now_for_column(column)
|
344
|
+
case column.type
|
345
|
+
when :tsrange, :tstzrange then "timezone('UTC', current_timestamp)"
|
346
|
+
when :daterange then "current_date"
|
347
|
+
else raise "Cannot generate 'now()' for #{column.type} column #{column.name}"
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
def primitive_type_for_column(column)
|
352
|
+
case column.type
|
353
|
+
when :tsrange then :timestamp
|
354
|
+
when :tstzrange then :timestamptz
|
355
|
+
when :daterange then :date
|
356
|
+
else raise "Don't know how to map #{column.type} column #{column.name} to a primitive type"
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
def build_time_query_at(time, range)
|
361
|
+
time = if time.kind_of?(Array)
|
362
|
+
time.map! {|t| time_for_time_query(t, range)}
|
363
|
+
else
|
364
|
+
time_for_time_query(time, range)
|
365
|
+
end
|
366
|
+
|
367
|
+
build_time_query(time, range)
|
368
|
+
end
|
369
|
+
|
370
|
+
def build_time_query(time, range)
|
371
|
+
if time.kind_of?(Array)
|
372
|
+
%[ #{range.type}(#{time.first}, #{time.last}) && #{table_name}.#{range.name} ]
|
373
|
+
else
|
374
|
+
%[ #{time} <@ #{table_name}.#{range.name} ]
|
375
|
+
end
|
353
376
|
end
|
354
377
|
end
|
355
378
|
|
@@ -358,10 +381,41 @@ module ChronoModel
|
|
358
381
|
module HistoryMethods
|
359
382
|
include TimeQuery
|
360
383
|
|
384
|
+
# In the History context, pre-fill the :on options with the validity interval.
|
385
|
+
#
|
386
|
+
def time_query(match, time, options = {})
|
387
|
+
options[:on] ||= :validity
|
388
|
+
super
|
389
|
+
end
|
390
|
+
|
391
|
+
def past
|
392
|
+
time_query(:before, :now).where('NOT upper_inf(validity)')
|
393
|
+
end
|
394
|
+
|
395
|
+
# To identify this class as the History subclass
|
396
|
+
def history?
|
397
|
+
true
|
398
|
+
end
|
399
|
+
|
400
|
+
# Getting the correct quoted_table_name can be tricky when
|
401
|
+
# STI is involved. If Orange < Fruit, then Orange::History < Fruit::History
|
402
|
+
# (see define_inherited_history_model_for).
|
403
|
+
# This means that the superclass method returns Fruit::History, which
|
404
|
+
# will give us the wrong table name. What we actually want is the
|
405
|
+
# superclass of Fruit::History, which is Fruit. So, we use
|
406
|
+
# non_history_superclass instead. -npj
|
407
|
+
def non_history_superclass(klass = self)
|
408
|
+
if klass.superclass.respond_to?(:history?) && klass.superclass.history?
|
409
|
+
non_history_superclass(klass.superclass)
|
410
|
+
else
|
411
|
+
klass.superclass
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
361
415
|
# Fetches as of +time+ records.
|
362
416
|
#
|
363
417
|
def as_of(time, scope = nil)
|
364
|
-
as_of =
|
418
|
+
as_of = non_history_superclass.unscoped.readonly.from(virtual_table_at(time))
|
365
419
|
|
366
420
|
# Add default scopes back if we're passed nil or a
|
367
421
|
# specific scope, because we're .unscopeing above.
|
@@ -383,7 +437,7 @@ module ChronoModel
|
|
383
437
|
|
384
438
|
def virtual_table_at(time, name = nil)
|
385
439
|
name = name ? connection.quote_table_name(name) :
|
386
|
-
|
440
|
+
non_history_superclass.quoted_table_name
|
387
441
|
|
388
442
|
"(#{at(time).to_sql}) #{name}"
|
389
443
|
end
|
@@ -391,23 +445,29 @@ module ChronoModel
|
|
391
445
|
# Fetches history record at the given time
|
392
446
|
#
|
393
447
|
def at(time)
|
394
|
-
time = Conversions.time_to_utc_string(time.utc) if time.kind_of?(Time)
|
448
|
+
time = Conversions.time_to_utc_string(time.utc) if time.kind_of?(Time) && !time.utc?
|
395
449
|
|
396
450
|
unscoped.
|
397
|
-
select("#{quoted_table_name}.*, #{connection.quote(time)} AS as_of_time").
|
398
|
-
time_query(:at, time
|
451
|
+
select("#{quoted_table_name}.*, #{connection.quote(time)}::timestamp AS as_of_time").
|
452
|
+
time_query(:at, time)
|
399
453
|
end
|
400
454
|
|
401
455
|
# Returns the whole history as read only.
|
402
456
|
#
|
403
457
|
def all
|
404
|
-
readonly
|
405
|
-
|
458
|
+
super.readonly
|
459
|
+
end
|
460
|
+
|
461
|
+
# Returns the history sorted by recorded_at
|
462
|
+
#
|
463
|
+
def sorted
|
464
|
+
all.order(%[ #{quoted_table_name}."recorded_at", #{quoted_table_name}."hid" ])
|
406
465
|
end
|
407
466
|
|
408
467
|
# Fetches the given +object+ history, sorted by history record time
|
409
468
|
# by default. Always includes an "as_of_time" column that is either
|
410
|
-
# the
|
469
|
+
# the upper bound of the validity range or now() if history validity
|
470
|
+
# is maximum.
|
411
471
|
#
|
412
472
|
def of(object)
|
413
473
|
readonly.where(:id => object).extend(HistorySelect)
|
@@ -425,11 +485,11 @@ module ChronoModel
|
|
425
485
|
return super if has_aggregate
|
426
486
|
|
427
487
|
if order_values.blank?
|
428
|
-
self.order_values += [
|
488
|
+
self.order_values += [ %[#{quoted_table_name}."recorded_at", #{quoted_table_name}."hid"] ]
|
429
489
|
end
|
430
490
|
|
431
491
|
super.tap do |rel|
|
432
|
-
rel.project("LEAST(
|
492
|
+
rel.project("LEAST(upper(validity), timezone('UTC', now())) AS as_of_time")
|
433
493
|
end
|
434
494
|
end
|
435
495
|
end
|
@@ -451,11 +511,15 @@ module ChronoModel
|
|
451
511
|
return [] if models.empty?
|
452
512
|
|
453
513
|
fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
|
454
|
-
fields.map! {|f| "#{f} + INTERVAL '2 usec'"}
|
455
514
|
|
456
515
|
relation = self.
|
457
|
-
|
458
|
-
|
516
|
+
select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts")
|
517
|
+
|
518
|
+
if assocs.present?
|
519
|
+
relation = relation.joins(*assocs.map(&:name))
|
520
|
+
end
|
521
|
+
|
522
|
+
relation = relation.
|
459
523
|
order('ts ' << (options[:reverse] ? 'DESC' : 'ASC'))
|
460
524
|
|
461
525
|
relation = relation.from(%["public".#{quoted_table_name}]) unless self.chrono?
|
@@ -471,11 +535,10 @@ module ChronoModel
|
|
471
535
|
sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after ])}'"
|
472
536
|
end
|
473
537
|
|
474
|
-
if rid
|
475
|
-
sql << (self.chrono? ? %
|
476
|
-
AND ts
|
477
|
-
|
478
|
-
] : %[ AND ts < NOW() ])
|
538
|
+
if rid && !options[:with]
|
539
|
+
sql << (self.chrono? ? %{
|
540
|
+
AND ts <@ ( SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid} )
|
541
|
+
} : %[ AND ts < NOW() ])
|
479
542
|
end
|
480
543
|
|
481
544
|
sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
|
@@ -510,10 +573,13 @@ module ChronoModel
|
|
510
573
|
end)
|
511
574
|
|
512
575
|
def quoted_history_fields
|
513
|
-
@quoted_history_fields ||=
|
514
|
-
|
515
|
-
|
516
|
-
|
576
|
+
@quoted_history_fields ||= begin
|
577
|
+
validity =
|
578
|
+
[connection.quote_table_name(table_name),
|
579
|
+
connection.quote_column_name('validity')
|
580
|
+
].join('.')
|
581
|
+
|
582
|
+
[:lower, :upper].map! {|func| "#{func}(#{validity})"}
|
517
583
|
end
|
518
584
|
end
|
519
585
|
end
|