chrono_model 1.0.1 → 1.1.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.
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