chrono_model 0.5.3 → 0.8.0
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.
- 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
|