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.
@@ -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 scoped
17
+ # then fetches the records from a virtual table using a subquery scope
18
18
  # to a specific timestamp.
19
- def scoped
20
- scoped = super
21
- return scoped unless _chrono_record?
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
- scoped = scoped.readonly.from(klass.history.virtual_table_at(owner.as_of_time))
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
- scoped.join_sources.each do |join|
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
- join.left = Arel::Nodes::SqlLiteral.new(v_table)
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 scoped
60
+ return scope
48
61
  end
49
62
 
50
63
  private
@@ -1,21 +1,9 @@
1
1
  module ChronoModel
2
2
  class Railtie < ::Rails::Railtie
3
- initializer :chrono_create_schemas do
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 = scoped.from("(#{virtual_table}) #{quoted_table_name}")
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 nil if self.valid_from.year.zero?
70
+ return if self.valid_from.nil?
76
71
 
77
72
  if self.class.timeline_associations.empty?
78
- self.class.where(:id => rid, :valid_to => valid_from_before_type_cast).first
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 nil if self.valid_to.year == 9999
83
+ return if self.valid_to.nil?
89
84
 
90
85
  if self.class.timeline_associations.empty?
91
- self.class.where(:id => rid, :valid_from => valid_to_before_type_cast).first
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(:valid_from).first
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(:valid_from).last
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
- # Hack around AR timezone support. These timestamps are recorded
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
- %w( valid_from valid_to recorded_at as_of_time ).each do |attr|
197
- define_method(attr) do
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('valid_to DESC').offset(1).first
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('hid desc').find(options[:id] || id)
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('hid desc').find(options[:id] || id)
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 valid_from valid_to recorded_at as_of_time )
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
- OPERATORS = {
312
- :at => '&&',
313
- :before => '<<',
314
- :after => '>>',
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
- def time_query(match, time_or_times, options)
318
- from_f, to_f = options[:on]
311
+ query = case match
312
+ when :at
313
+ build_time_query_at(time, range)
319
314
 
320
- from_t, to_t = if time_or_times.kind_of?(Array)
321
- time_or_times.map! {|t| time_for_time_query(t)}
322
- else
323
- [time_for_time_query(time_or_times)]*2
324
- end
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
- where(build_time_query(match, from_t, from_f, to_t, to_f))
325
+ raise ArgumentError, "Invalid time_query: #{match}"
333
326
  end
327
+
328
+ where(query)
334
329
  end
335
330
 
336
331
  private
337
- def time_for_time_query(t)
338
- t = Conversions.time_to_utc_string(t.utc) if t.kind_of?(Time)
339
- t == :now ? 'now()' : "#{connection.quote(t)}::timestamp"
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 build_time_query(match, from_t, from_f, to_t, to_f)
343
- %[
344
- box(
345
- point( date_part( 'epoch', #{from_f} ), 0 ),
346
- point( date_part( 'epoch', #{to_f } ), 0 )
347
- ) #{OPERATORS.fetch(match)}
348
- box(
349
- point( date_part( 'epoch', #{from_t} ), 0 ),
350
- point( date_part( 'epoch', #{to_t } ), 0 )
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 = superclass.unscoped.readonly.from(virtual_table_at(time))
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
- superclass.quoted_table_name
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, :on => quoted_history_fields)
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
- order("#{quoted_table_name}.recorded_at, hid").all
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 valid_to timestamp or now() if history validity is maximum.
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 += ["#{quoted_table_name}.recorded_at, #{quoted_table_name}.hid"]
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(valid_to, now()::timestamp) AS as_of_time")
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
- joins(*assocs.map(&:name)).
458
- select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts").
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 >= ( SELECT MIN(valid_from) FROM #{quoted_table_name} WHERE id = #{rid} )
477
- AND ts < ( SELECT MAX(valid_to ) FROM #{quoted_table_name} WHERE id = #{rid} )
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 ||= [:valid_from, :valid_to].map do |field|
514
- [connection.quote_table_name(table_name),
515
- connection.quote_column_name(field)
516
- ].join('.')
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