chrono_model 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +19 -14
  3. data/README.md +49 -25
  4. data/lib/chrono_model.rb +37 -3
  5. data/lib/chrono_model/adapter.rb +91 -874
  6. data/lib/chrono_model/adapter/ddl.rb +225 -0
  7. data/lib/chrono_model/adapter/indexes.rb +194 -0
  8. data/lib/chrono_model/adapter/migrations.rb +282 -0
  9. data/lib/chrono_model/adapter/tsrange.rb +57 -0
  10. data/lib/chrono_model/adapter/upgrade.rb +120 -0
  11. data/lib/chrono_model/conversions.rb +20 -0
  12. data/lib/chrono_model/json.rb +28 -0
  13. data/lib/chrono_model/patches.rb +8 -232
  14. data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
  15. data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
  16. data/lib/chrono_model/patches/association.rb +52 -0
  17. data/lib/chrono_model/patches/db_console.rb +11 -0
  18. data/lib/chrono_model/patches/join_node.rb +32 -0
  19. data/lib/chrono_model/patches/preloader.rb +68 -0
  20. data/lib/chrono_model/patches/relation.rb +58 -0
  21. data/lib/chrono_model/time_gate.rb +5 -5
  22. data/lib/chrono_model/time_machine.rb +47 -427
  23. data/lib/chrono_model/time_machine/history_model.rb +196 -0
  24. data/lib/chrono_model/time_machine/time_query.rb +86 -0
  25. data/lib/chrono_model/time_machine/timeline.rb +94 -0
  26. data/lib/chrono_model/utilities.rb +27 -0
  27. data/lib/chrono_model/version.rb +1 -1
  28. data/spec/aruba/dbconsole_spec.rb +25 -0
  29. data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
  30. data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
  31. data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
  32. data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
  33. data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
  34. data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
  35. data/spec/config.travis.yml +1 -0
  36. data/spec/config.yml.example +1 -0
  37. metadata +35 -14
  38. data/lib/chrono_model/utils.rb +0 -117
@@ -0,0 +1,23 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ # Added to classes that need to carry the As-Of date around
5
+ #
6
+ module AsOfTimeHolder
7
+ # Sets the virtual 'as_of_time' attribute to the given time, converting to UTC.
8
+ #
9
+ def as_of_time!(time)
10
+ @_as_of_time = time.utc
11
+
12
+ self
13
+ end
14
+
15
+ # Reads the virtual 'as_of_time' attribute
16
+ #
17
+ def as_of_time
18
+ @_as_of_time
19
+ end
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ # This class is a dummy relation whose scope is only to pass around the
5
+ # as_of_time parameters across ActiveRecord call chains.
6
+ #
7
+ # With AR 5.2 a simple relation can be used, as the only required argument
8
+ # is the model. 5.0 and 5.1 require more arguments, that are passed here.
9
+ #
10
+ class AsOfTimeRelation < ActiveRecord::Relation
11
+ if ActiveRecord::VERSION::STRING.to_f < 5.2
12
+ def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {})
13
+ super(klass, table, predicate_builder, values)
14
+ end
15
+ end
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ # Patches ActiveRecord::Associations::Association to add support for
5
+ # temporal associations.
6
+ #
7
+ # Each record fetched from the +as_of+ scope on the owner class will have
8
+ # an additional "as_of_time" field yielding the UTC time of the request,
9
+ # then the as_of scope is called on either this association's class or
10
+ # on the join model's (:through association) one.
11
+ #
12
+ module Association
13
+ def skip_statement_cache?(*)
14
+ super || _chrono_target?
15
+ end
16
+
17
+ # If the association class or the through association are ChronoModels,
18
+ # then fetches the records from a virtual table using a subquery scope
19
+ # to a specific timestamp.
20
+ def scope
21
+ scope = super
22
+ return scope unless _chrono_record?
23
+
24
+ if _chrono_target?
25
+ # For standard associations, replace the table name with the virtual
26
+ # as-of table name at the owner's as-of-time
27
+ #
28
+ scope = scope.from(klass.history.virtual_table_at(owner.as_of_time))
29
+ end
30
+
31
+ scope.as_of_time!(owner.as_of_time)
32
+
33
+ return scope
34
+ end
35
+
36
+ private
37
+ def _chrono_record?
38
+ owner.respond_to?(:as_of_time) && owner.as_of_time.present?
39
+ end
40
+
41
+ def _chrono_target?
42
+ @_target_klass ||= reflection.options[:polymorphic] ?
43
+ owner.public_send(reflection.foreign_type).constantize :
44
+ reflection.klass
45
+
46
+ @_target_klass.chrono?
47
+ end
48
+
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ module DBConsole
5
+ def config
6
+ super.dup.tap {|config| config['adapter'] = 'postgresql/chronomodel' }
7
+ end
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ # This class supports the AR 5.0 code that expects to receive an
5
+ # Arel::Table as the left join node. We need to replace the node
6
+ # with a virtual table that fetches from the history at a given
7
+ # point in time, we replace the join node with a SqlLiteral node
8
+ # that does not respond to the methods that AR expects.
9
+ #
10
+ # This class provides AR with an object implementing the methods
11
+ # it expects, yet producing SQL that fetches from history tables
12
+ # as-of-time.
13
+ #
14
+ class JoinNode < Arel::Nodes::SqlLiteral
15
+ attr_reader :name, :table_name, :table_alias, :as_of_time
16
+
17
+ def initialize(join_node, history_model, as_of_time)
18
+ @name = join_node.table_name
19
+ @table_name = join_node.table_name
20
+ @table_alias = join_node.table_alias
21
+
22
+ @as_of_time = as_of_time
23
+
24
+ virtual_table = history_model.
25
+ virtual_table_at(@as_of_time, @table_alias || @table_name)
26
+
27
+ super(virtual_table)
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,68 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ # Patches ActiveRecord::Associations::Preloader to add support for
5
+ # temporal associations. This is tying itself to Rails internals
6
+ # and it is ugly :-(.
7
+ #
8
+ module Preloader
9
+ attr_reader :options
10
+
11
+ # We overwrite the initializer in order to pass the +as_of_time+
12
+ # parameter above in the build_preloader
13
+ #
14
+ def initialize(options = {})
15
+ @options = options.freeze
16
+ end
17
+
18
+ # Patches the AR Preloader (lib/active_record/associations/preloader.rb)
19
+ # in order to carry around the +as_of_time+ of the original invocation.
20
+ #
21
+ # * The +records+ are the parent records where the association is defined
22
+ # * The +associations+ are the association names involved in preloading
23
+ # * The +given_preload_scope+ is the preloading scope, that is used only
24
+ # in the :through association and it holds the intermediate records
25
+ # _through_ which the final associated records are eventually fetched.
26
+ #
27
+ # As the +preload_scope+ is passed around to all the different
28
+ # incarnations of the preloader strategies, we are using it to pass
29
+ # around the +as_of_time+ of the original query invocation, so that
30
+ # preloaded records are preloaded honoring the +as_of_time+.
31
+ #
32
+ # The +preload_scope+ is present only in through associations, but the
33
+ # preloader interfaces expect it to be always defined, for consistency.
34
+ #
35
+ # For `:through` associations, the +given_preload_scope+ is already a
36
+ # +Relation+, that already has the +as_of_time+ getters and setters,
37
+ # so we use it directly.
38
+ #
39
+ def preload(records, associations, given_preload_scope = nil)
40
+ if options[:as_of_time]
41
+ preload_scope = given_preload_scope ||
42
+ ChronoModel::Patches::AsOfTimeRelation.new(options[:model])
43
+
44
+ preload_scope.as_of_time!(options[:as_of_time])
45
+ end
46
+
47
+ super records, associations, preload_scope
48
+ end
49
+
50
+ module Association
51
+ # Builds the preloader scope taking into account a potential
52
+ # +as_of_time+ passed down the call chain starting at the
53
+ # end user invocation.
54
+ #
55
+ def build_scope
56
+ scope = super
57
+
58
+ if preload_scope.try(:as_of_time)
59
+ scope = scope.as_of(preload_scope.as_of_time)
60
+ end
61
+
62
+ return scope
63
+ end
64
+ end
65
+ end
66
+
67
+ end
68
+ end
@@ -0,0 +1,58 @@
1
+ module ChronoModel
2
+ module Patches
3
+
4
+ module Relation
5
+ include ChronoModel::Patches::AsOfTimeHolder
6
+
7
+ def load
8
+ return super unless @_as_of_time && !loaded?
9
+
10
+ super.each {|record| record.as_of_time!(@_as_of_time) }
11
+ end
12
+
13
+ def merge(*)
14
+ return super unless @_as_of_time
15
+
16
+ super.as_of_time!(@_as_of_time)
17
+ end
18
+
19
+ def build_arel(*)
20
+ return super unless @_as_of_time
21
+
22
+ super.tap do |arel|
23
+
24
+ arel.join_sources.each do |join|
25
+ chrono_join_history(join)
26
+ end
27
+
28
+ end
29
+ end
30
+
31
+ # Replaces a join with the current data with another that
32
+ # loads records As-Of time against the history data.
33
+ #
34
+ def chrono_join_history(join)
35
+ # This case happens with nested includes, where the below
36
+ # code has already replaced the join.left with a JoinNode.
37
+ #
38
+ return if join.left.respond_to?(:as_of_time)
39
+
40
+ model = ChronoModel.history_models[join.left.table_name]
41
+ return unless model
42
+
43
+ join.left = ChronoModel::Patches::JoinNode.new(
44
+ join.left, model.history, @_as_of_time)
45
+ end
46
+
47
+ # Build a preloader at the +as_of_time+ of this relation.
48
+ # Pass the current model to define Relation
49
+ #
50
+ def build_preloader
51
+ ActiveRecord::Associations::Preloader.new(
52
+ model: self.model, as_of_time: as_of_time
53
+ )
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -6,18 +6,18 @@ module ChronoModel
6
6
  module TimeGate
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ include ChronoModel::Patches::AsOfTimeHolder
10
+
9
11
  module ClassMethods
12
+ include ChronoModel::TimeMachine::Timeline
13
+
10
14
  def as_of(time)
11
15
  all.as_of_time!(time)
12
16
  end
13
-
14
- include TimeMachine::HistoryMethods::Timeline
15
17
  end
16
18
 
17
- include Patches::AsOfTimeHolder
18
-
19
19
  def as_of(time)
20
- self.class.as_of(time).where(:id => self.id).first!
20
+ self.class.as_of(time).where(id: self.id).first!
21
21
  end
22
22
 
23
23
  def timeline
@@ -1,11 +1,13 @@
1
- require 'active_record'
1
+ require 'chrono_model/time_machine/time_query'
2
+ require 'chrono_model/time_machine/timeline'
3
+ require 'chrono_model/time_machine/history_model'
2
4
 
3
5
  module ChronoModel
4
6
 
5
7
  module TimeMachine
6
- extend ActiveSupport::Concern
8
+ include ChronoModel::Patches::AsOfTimeHolder
7
9
 
8
- include Patches::AsOfTimeHolder
10
+ extend ActiveSupport::Concern
9
11
 
10
12
  included do
11
13
  if table_exists? && !chrono?
@@ -13,8 +15,8 @@ module ChronoModel
13
15
  "Please use `change_table :#{table_name}, temporal: true` in a migration."
14
16
  end
15
17
 
16
- history = TimeMachine.define_history_model_for(self)
17
- TimeMachine.chrono_models[table_name] = history
18
+ history = ChronoModel::TimeMachine.define_history_model_for(self)
19
+ ChronoModel.history_models[table_name] = history
18
20
 
19
21
  class << self
20
22
  alias_method :direct_descendants_with_history, :direct_descendants
@@ -36,130 +38,14 @@ module ChronoModel
36
38
  # new anonymous class, without this check this leads to
37
39
  # infinite recursion.
38
40
  unless subclass.name.nil?
39
- TimeMachine.define_inherited_history_model_for(subclass)
41
+ ChronoModel::TimeMachine.define_inherited_history_model_for(subclass)
40
42
  end
41
43
  end
42
44
  end
43
45
  end
44
46
 
45
- # Returns an Hash keyed by table name of ChronoModels
46
- #
47
- def self.chrono_models
48
- (@chrono_models ||= {})
49
- end
50
-
51
47
  def self.define_history_model_for(model)
52
- history = Class.new(model) do
53
- self.table_name = [Adapter::HISTORY_SCHEMA, model.table_name].join('.')
54
-
55
- extend TimeMachine::HistoryMethods
56
-
57
- scope :chronological, -> { order(Arel.sql('lower(validity) ASC')) }
58
-
59
- # The history id is `hid`, but this cannot set as primary key
60
- # or temporal assocations will break. Solutions are welcome.
61
- def id
62
- hid
63
- end
64
-
65
- # Referenced record ID.
66
- #
67
- def rid
68
- attributes[self.class.primary_key]
69
- end
70
-
71
- # HACK. find() and save() require the real history ID. So we are
72
- # setting it now and ensuring to reset it to the original one after
73
- # execution completes.
74
- #
75
- def self.with_hid_pkey(&block)
76
- old = self.primary_key
77
- self.primary_key = :hid
78
-
79
- block.call
80
- ensure
81
- self.primary_key = old
82
- end
83
-
84
- def self.find(*)
85
- with_hid_pkey { super }
86
- end
87
-
88
- def save(*)
89
- self.class.with_hid_pkey { super }
90
- end
91
-
92
- def save!(*)
93
- self.class.with_hid_pkey { super }
94
- end
95
-
96
- def update_columns(*)
97
- self.class.with_hid_pkey { super }
98
- end
99
-
100
- # Returns the previous history entry, or nil if this
101
- # is the first one.
102
- #
103
- def pred
104
- return if self.valid_from.nil?
105
-
106
- if self.class.timeline_associations.empty?
107
- self.class.where('id = ? AND upper(validity) = ?', rid, valid_from).first
108
- else
109
- super(:id => rid, :before => valid_from, :table => self.class.superclass.quoted_table_name)
110
- end
111
- end
112
-
113
- # Returns the next history entry, or nil if this is the
114
- # last one.
115
- #
116
- def succ
117
- return if self.valid_to.nil?
118
-
119
- if self.class.timeline_associations.empty?
120
- self.class.where('id = ? AND lower(validity) = ?', rid, valid_to).first
121
- else
122
- super(:id => rid, :after => valid_to, :table => self.class.superclass.quoted_table_name)
123
- end
124
- end
125
- alias :next :succ
126
-
127
- # Returns the first history entry
128
- #
129
- def first
130
- self.class.where(:id => rid).chronological.first
131
- end
132
-
133
- # Returns the last history entry
134
- #
135
- def last
136
- self.class.where(:id => rid).chronological.last
137
- end
138
-
139
- # Returns this history entry's current record
140
- #
141
- def current_version
142
- self.class.non_history_superclass.find(rid)
143
- end
144
-
145
- def record #:nodoc:
146
- ActiveSupport::Deprecation.warn '.record is deprecated in favour of .current_version'
147
- self.current_version
148
- end
149
-
150
- def valid_from
151
- validity.first
152
- end
153
-
154
- def valid_to
155
- validity.last
156
- end
157
- alias as_of_time valid_to
158
-
159
- def recorded_at
160
- Conversions.string_to_utc_time attributes_before_type_cast['recorded_at']
161
- end
162
- end
48
+ history = Class.new(model) { include ChronoModel::TimeMachine::HistoryModel }
163
49
 
164
50
  model.singleton_class.instance_eval do
165
51
  define_method(:history) { history }
@@ -203,6 +89,37 @@ module ChronoModel
203
89
  end
204
90
  end
205
91
 
92
+ module ClassMethods
93
+ # Identify this class as the parent, non-history, class.
94
+ #
95
+ def history?
96
+ false
97
+ end
98
+
99
+ # Returns an ActiveRecord::Relation on the history of this model as
100
+ # it was +time+ ago.
101
+ def as_of(time)
102
+ history.as_of(time)
103
+ end
104
+
105
+ def attribute_names_for_history_changes
106
+ @attribute_names_for_history_changes ||= attribute_names -
107
+ %w( id hid validity recorded_at )
108
+ end
109
+
110
+ def has_timeline(options)
111
+ changes = options.delete(:changes)
112
+ assocs = history.has_timeline(options)
113
+
114
+ attributes = changes.present? ?
115
+ Array.wrap(changes) : assocs.map(&:name)
116
+
117
+ attribute_names_for_history_changes.concat(attributes.map(&:to_s))
118
+ end
119
+
120
+ delegate :timeline_associations, to: :history
121
+ end
122
+
206
123
  # Returns a read-only representation of this record as it was +time+ ago.
207
124
  # Returns nil if no record is found.
208
125
  #
@@ -217,12 +134,12 @@ module ChronoModel
217
134
  _as_of(time).first!
218
135
  end
219
136
 
220
- # Delegates to +HistoryMethods.as_of+ to fetch this instance as it was on
221
- # +time+. Used both by +as_of+ and +as_of!+ for performance reasons, to
222
- # avoid a `rescue` (@lleirborras).
137
+ # Delegates to +HistoryModel::ClassMethods.as_of+ to fetch this instance
138
+ # as it was on +time+. Used both by +as_of+ and +as_of!+ for performance
139
+ # reasons, to avoid a `rescue` (@lleirborras).
223
140
  #
224
141
  def _as_of(time)
225
- self.class.as_of(time).where(:id => self.id)
142
+ self.class.as_of(time).where(id: self.id)
226
143
  end
227
144
  protected :_as_of
228
145
 
@@ -279,26 +196,10 @@ module ChronoModel
279
196
  end
280
197
  end
281
198
 
282
- # Returns the next record in the history timeline.
199
+ # This is a current record, so its next instance is always nil.
283
200
  #
284
- def succ(options = {})
285
- unless self.class.timeline_associations.empty?
286
- return nil unless (ts = succ_timestamp(options))
287
-
288
- order_clause = Arel.sql %[ LOWER(#{options[:table] || self.class.quoted_table_name}."validity"_ DESC ]
289
-
290
- self.class.as_of(ts).order(order_clause).find(options[:id] || id)
291
- end
292
- end
293
-
294
- # Returns the next timestamp in this record's timeline. Includes temporal
295
- # associations.
296
- #
297
- def succ_timestamp(options = {})
298
- return nil unless historical?
299
-
300
- options[:after] ||= as_of_time
301
- timeline(options.merge(limit: 1, reverse: false)).first
201
+ def succ
202
+ nil
302
203
  end
303
204
 
304
205
  # Returns the current history version
@@ -333,287 +234,6 @@ module ChronoModel
333
234
  end
334
235
  end
335
236
 
336
- module ClassMethods
337
- # Identify this class as the parent, non-history, class.
338
- #
339
- def history?
340
- false
341
- end
342
-
343
- # Returns an ActiveRecord::Relation on the history of this model as
344
- # it was +time+ ago.
345
- def as_of(time)
346
- history.as_of(time)
347
- end
348
-
349
- def attribute_names_for_history_changes
350
- @attribute_names_for_history_changes ||= attribute_names -
351
- %w( id hid validity recorded_at )
352
- end
353
-
354
- def has_timeline(options)
355
- changes = options.delete(:changes)
356
- assocs = history.has_timeline(options)
357
-
358
- attributes = changes.present? ?
359
- Array.wrap(changes) : assocs.map(&:name)
360
-
361
- attribute_names_for_history_changes.concat(attributes.map(&:to_s))
362
- end
363
-
364
- delegate :timeline_associations, :to => :history
365
- end
366
-
367
- module TimeQuery
368
- # TODO Documentation
369
- #
370
- def time_query(match, time, options)
371
- range = columns_hash.fetch(options[:on].to_s)
372
-
373
- query = case match
374
- when :at
375
- build_time_query_at(time, range)
376
-
377
- when :not
378
- "NOT (#{build_time_query_at(time, range)})"
379
-
380
- when :before
381
- op = options.fetch(:inclusive, true) ? '&&' : '@>'
382
- build_time_query(['NULL', time_for_time_query(time, range)], range, op)
383
-
384
- when :after
385
- op = options.fetch(:inclusive, true) ? '&&' : '@>'
386
- build_time_query([time_for_time_query(time, range), 'NULL'], range, op)
387
-
388
- else
389
- raise ArgumentError, "Invalid time_query: #{match}"
390
- end
391
-
392
- where(query)
393
- end
394
-
395
- private
396
-
397
- def time_for_time_query(t, column)
398
- if t == :now || t == :today
399
- now_for_column(column)
400
- else
401
- quoted_t = connection.quote(connection.quoted_date(t))
402
- [quoted_t, primitive_type_for_column(column)].join('::')
403
- end
404
- end
405
-
406
- def now_for_column(column)
407
- case column.type
408
- when :tsrange, :tstzrange then "timezone('UTC', current_timestamp)"
409
- when :daterange then "current_date"
410
- else raise "Cannot generate 'now()' for #{column.type} column #{column.name}"
411
- end
412
- end
413
-
414
- def primitive_type_for_column(column)
415
- case column.type
416
- when :tsrange then :timestamp
417
- when :tstzrange then :timestamptz
418
- when :daterange then :date
419
- else raise "Don't know how to map #{column.type} column #{column.name} to a primitive type"
420
- end
421
- end
422
-
423
- def build_time_query_at(time, range)
424
- time = if time.kind_of?(Array)
425
- time.map! {|t| time_for_time_query(t, range)}
426
-
427
- # If both edges of the range are the same the query fails using the '&&' operator.
428
- # The correct solution is to use the <@ operator.
429
- time.first == time.last ? time.first : time
430
- else
431
- time_for_time_query(time, range)
432
- end
433
-
434
- build_time_query(time, range)
435
- end
436
-
437
- def build_time_query(time, range, op = '&&')
438
- if time.kind_of?(Array)
439
- Arel.sql %[ #{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
440
- else
441
- Arel.sql %[ #{time} <@ #{table_name}.#{range.name} ]
442
- end
443
- end
444
- end
445
-
446
- # Methods that make up the history interface of the companion History
447
- # model, automatically built for each Model that includes TimeMachine
448
- module HistoryMethods
449
- include TimeQuery
450
-
451
- # In the History context, pre-fill the :on options with the validity interval.
452
- #
453
- def time_query(match, time, options = {})
454
- options[:on] ||= :validity
455
- super
456
- end
457
-
458
- def past
459
- time_query(:before, :now).where("NOT upper_inf(#{quoted_table_name}.validity)")
460
- end
461
-
462
- # To identify this class as the History subclass
463
- def history?
464
- true
465
- end
466
-
467
- # Getting the correct quoted_table_name can be tricky when
468
- # STI is involved. If Orange < Fruit, then Orange::History < Fruit::History
469
- # (see define_inherited_history_model_for).
470
- # This means that the superclass method returns Fruit::History, which
471
- # will give us the wrong table name. What we actually want is the
472
- # superclass of Fruit::History, which is Fruit. So, we use
473
- # non_history_superclass instead. -npj
474
- def non_history_superclass(klass = self)
475
- if klass.superclass.history?
476
- non_history_superclass(klass.superclass)
477
- else
478
- klass.superclass
479
- end
480
- end
481
-
482
- def relation
483
- super.as_of_time!(Time.now)
484
- end
485
-
486
- # Fetches as of +time+ records.
487
- #
488
- def as_of(time)
489
- non_history_superclass.from(virtual_table_at(time)).as_of_time!(time)
490
- end
491
-
492
- def virtual_table_at(time, name = nil)
493
- name = name ? connection.quote_table_name(name) :
494
- non_history_superclass.quoted_table_name
495
-
496
- "(#{at(time).to_sql}) #{name}"
497
- end
498
-
499
- # Fetches history record at the given time
500
- #
501
- def at(time)
502
- time_query(:at, time).from(quoted_table_name).as_of_time!(time)
503
- end
504
-
505
- # Returns the history sorted by recorded_at
506
- #
507
- def sorted
508
- all.order(Arel.sql(%[ #{quoted_table_name}."recorded_at" ASC, #{quoted_table_name}."hid" ASC ]))
509
- end
510
-
511
- # Fetches the given +object+ history, sorted by history record time
512
- # by default. Always includes an "as_of_time" column that is either
513
- # the upper bound of the validity range or now() if history validity
514
- # is maximum.
515
- #
516
- def of(object)
517
- where(:id => object)
518
- end
519
-
520
- # FIXME Remove, this was a workaround to a former design flaw.
521
- #
522
- # - vjt Wed Oct 28 17:13:57 CET 2015
523
- #
524
- def force_history_fields
525
- self
526
- end
527
-
528
- include(Timeline = Module.new do
529
- # Returns an Array of unique UTC timestamps for which at least an
530
- # history record exists. Takes temporal associations into account.
531
- #
532
- def timeline(record = nil, options = {})
533
- rid = record.respond_to?(:rid) ? record.rid : record.id if record
534
-
535
- assocs = options.key?(:with) ?
536
- timeline_associations_from(options[:with]) : timeline_associations
537
-
538
- models = []
539
- models.push self if self.chrono?
540
- models.concat(assocs.map {|a| a.klass.history})
541
-
542
- return [] if models.empty?
543
-
544
- fields = models.inject([]) {|a,m| a.concat m.quoted_history_fields}
545
-
546
- relation = self.except(:order).
547
- select("DISTINCT UNNEST(ARRAY[#{fields.join(',')}]) AS ts")
548
-
549
- if assocs.present?
550
- relation = relation.joins(*assocs.map(&:name))
551
- end
552
-
553
- relation = relation.
554
- order('ts ' << (options[:reverse] ? 'DESC' : 'ASC'))
555
-
556
- relation = relation.from(%["public".#{quoted_table_name}]) unless self.chrono?
557
- relation = relation.where(:id => rid) if rid
558
-
559
- sql = "SELECT ts FROM ( #{relation.to_sql} ) foo WHERE ts IS NOT NULL"
560
-
561
- if options.key?(:before)
562
- sql << " AND ts < '#{Conversions.time_to_utc_string(options[:before])}'"
563
- end
564
-
565
- if options.key?(:after)
566
- sql << " AND ts > '#{Conversions.time_to_utc_string(options[:after ])}'"
567
- end
568
-
569
- if rid && !options[:with]
570
- sql << (self.chrono? ? %{
571
- AND ts <@ ( SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid} )
572
- } : %[ AND ts < NOW() ])
573
- end
574
-
575
- sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
576
-
577
- sql.gsub! 'INNER JOIN', 'LEFT OUTER JOIN'
578
-
579
- connection.on_schema(Adapter::HISTORY_SCHEMA) do
580
- connection.select_values(sql, "#{self.name} periods").map! do |ts|
581
- Conversions.string_to_utc_time ts
582
- end
583
- end
584
- end
585
-
586
- def has_timeline(options)
587
- options.assert_valid_keys(:with)
588
-
589
- timeline_associations_from(options[:with]).tap do |assocs|
590
- timeline_associations.concat assocs
591
- end
592
- end
593
-
594
- def timeline_associations
595
- @timeline_associations ||= []
596
- end
597
-
598
- def timeline_associations_from(names)
599
- Array.wrap(names).map do |name|
600
- reflect_on_association(name) or raise ArgumentError,
601
- "No association found for name `#{name}'"
602
- end
603
- end
604
- end)
605
-
606
- def quoted_history_fields
607
- @quoted_history_fields ||= begin
608
- validity =
609
- [connection.quote_table_name(table_name),
610
- connection.quote_column_name('validity')
611
- ].join('.')
612
-
613
- [:lower, :upper].map! {|func| "#{func}(#{validity})"}
614
- end
615
- end
616
- end
617
237
  end
618
238
 
619
239
  end