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