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.
- checksums.yaml +5 -5
- data/.travis.yml +19 -14
- data/README.md +49 -25
- data/lib/chrono_model.rb +37 -3
- data/lib/chrono_model/adapter.rb +91 -874
- data/lib/chrono_model/adapter/ddl.rb +225 -0
- data/lib/chrono_model/adapter/indexes.rb +194 -0
- data/lib/chrono_model/adapter/migrations.rb +282 -0
- data/lib/chrono_model/adapter/tsrange.rb +57 -0
- data/lib/chrono_model/adapter/upgrade.rb +120 -0
- data/lib/chrono_model/conversions.rb +20 -0
- data/lib/chrono_model/json.rb +28 -0
- data/lib/chrono_model/patches.rb +8 -232
- data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
- data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
- data/lib/chrono_model/patches/association.rb +52 -0
- data/lib/chrono_model/patches/db_console.rb +11 -0
- data/lib/chrono_model/patches/join_node.rb +32 -0
- data/lib/chrono_model/patches/preloader.rb +68 -0
- data/lib/chrono_model/patches/relation.rb +58 -0
- data/lib/chrono_model/time_gate.rb +5 -5
- data/lib/chrono_model/time_machine.rb +47 -427
- data/lib/chrono_model/time_machine/history_model.rb +196 -0
- data/lib/chrono_model/time_machine/time_query.rb +86 -0
- data/lib/chrono_model/time_machine/timeline.rb +94 -0
- data/lib/chrono_model/utilities.rb +27 -0
- data/lib/chrono_model/version.rb +1 -1
- data/spec/aruba/dbconsole_spec.rb +25 -0
- data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
- data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
- data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
- data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
- data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
- data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
- data/spec/config.travis.yml +1 -0
- data/spec/config.yml.example +1 -0
- metadata +35 -14
- 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,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(:
|
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 '
|
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
|
-
|
8
|
+
include ChronoModel::Patches::AsOfTimeHolder
|
7
9
|
|
8
|
-
|
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
|
-
|
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)
|
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 +
|
221
|
-
# +time+. Used both by +as_of+ and +as_of!+ for performance
|
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(:
|
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
|
-
#
|
199
|
+
# This is a current record, so its next instance is always nil.
|
283
200
|
#
|
284
|
-
def succ
|
285
|
-
|
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
|