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.
@@ -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