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,57 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module TSRange
|
5
|
+
# HACK: Redefine tsrange parsing support, as it is broken currently.
|
6
|
+
#
|
7
|
+
# This self-made API is here because currently AR4 does not support
|
8
|
+
# open-ended ranges. The reasons are poor support in Ruby:
|
9
|
+
#
|
10
|
+
# https://bugs.ruby-lang.org/issues/6864
|
11
|
+
#
|
12
|
+
# and an instable interface in Active Record:
|
13
|
+
#
|
14
|
+
# https://github.com/rails/rails/issues/13793
|
15
|
+
# https://github.com/rails/rails/issues/14010
|
16
|
+
#
|
17
|
+
# so, for now, we are implementing our own.
|
18
|
+
#
|
19
|
+
class Type < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
|
20
|
+
OID = 3908
|
21
|
+
|
22
|
+
def cast_value(value)
|
23
|
+
return if value == 'empty'
|
24
|
+
return value if value.is_a?(::Array)
|
25
|
+
|
26
|
+
extracted = extract_bounds(value)
|
27
|
+
|
28
|
+
from = Conversions.string_to_utc_time extracted[:from]
|
29
|
+
to = Conversions.string_to_utc_time extracted[:to ]
|
30
|
+
|
31
|
+
[from, to]
|
32
|
+
end
|
33
|
+
|
34
|
+
def extract_bounds(value)
|
35
|
+
from, to = value[1..-2].split(',')
|
36
|
+
{
|
37
|
+
from: (value[1] == ',' || from == '-infinity') ? nil : from[1..-2],
|
38
|
+
to: (value[-2] == ',' || to == 'infinity') ? nil : to[1..-2],
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize_type_map(m = type_map)
|
44
|
+
super.tap do
|
45
|
+
typ = ChronoModel::Adapter::TSRange::Type
|
46
|
+
oid = typ::OID
|
47
|
+
|
48
|
+
ar_type = type_map.fetch(oid)
|
49
|
+
cm_type = typ.new(ar_type.subtype, ar_type.type)
|
50
|
+
|
51
|
+
type_map.register_type oid, cm_type
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module Upgrade
|
5
|
+
private
|
6
|
+
def chrono_upgrade!
|
7
|
+
chrono_ensure_schemas
|
8
|
+
|
9
|
+
chrono_upgrade_structure!
|
10
|
+
end
|
11
|
+
|
12
|
+
# Locate tables needing a structure upgrade
|
13
|
+
#
|
14
|
+
def chrono_tables_needing_upgrade
|
15
|
+
tables = { }
|
16
|
+
|
17
|
+
on_temporal_schema { self.tables }.each do |table_name|
|
18
|
+
next unless is_chrono?(table_name)
|
19
|
+
metadata = chrono_metadata_for(table_name)
|
20
|
+
version = metadata['chronomodel']
|
21
|
+
|
22
|
+
if version.blank?
|
23
|
+
tables[table_name] = { version: nil, priority: 'CRITICAL' }
|
24
|
+
elsif version != VERSION
|
25
|
+
tables[table_name] = { version: version, priority: 'LOW' }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
return tables
|
30
|
+
end
|
31
|
+
|
32
|
+
# Emit a warning about tables needing an upgrade
|
33
|
+
#
|
34
|
+
def chrono_upgrade_warning
|
35
|
+
upgrade = chrono_tables_needing_upgrade.map do |table, desc|
|
36
|
+
"#{table} - priority: #{desc[:priority]}"
|
37
|
+
end.join('; ')
|
38
|
+
|
39
|
+
return if upgrade.empty?
|
40
|
+
|
41
|
+
logger.warn "ChronoModel: There are tables needing a structure upgrade, and ChronoModel structures need to be recreated."
|
42
|
+
logger.warn "ChronoModel: Please run ChronoModel.upgrade! to attempt the upgrade. If you have dependant database objects"
|
43
|
+
logger.warn "ChronoModel: the upgrade will fail and you have to drop the dependent objects, run .upgrade! and create them"
|
44
|
+
logger.warn "ChronoModel: again. Sorry. Some features or the whole library may not work correctly until upgrade is complete."
|
45
|
+
logger.warn "ChronoModel: Tables pending upgrade: #{upgrade}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Upgrades existing structure for each table, if required.
|
49
|
+
#
|
50
|
+
def chrono_upgrade_structure!
|
51
|
+
transaction do
|
52
|
+
|
53
|
+
chrono_tables_needing_upgrade.each do |table_name, desc|
|
54
|
+
|
55
|
+
if desc[:version].blank?
|
56
|
+
logger.info "ChronoModel: Upgrading legacy PG 9.0 table #{table_name} to #{VERSION}"
|
57
|
+
chrono_upgrade_from_postgres_9_0(table_name)
|
58
|
+
logger.info "ChronoModel: legacy #{table_name} upgrade complete"
|
59
|
+
else
|
60
|
+
logger.info "ChronoModel: upgrading #{table_name} from #{desc[:version]} to #{VERSION}"
|
61
|
+
chrono_create_view_for(table_name)
|
62
|
+
logger.info "ChronoModel: #{table_name} upgrade complete"
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
67
|
+
rescue => e
|
68
|
+
message = "ChronoModel structure upgrade failed: #{e.message}. Please drop dependent objects first and then run ChronoModel.upgrade! again."
|
69
|
+
|
70
|
+
# Quite important, output it also to stderr.
|
71
|
+
#
|
72
|
+
logger.error message
|
73
|
+
$stderr.puts message
|
74
|
+
end
|
75
|
+
|
76
|
+
def chrono_upgrade_from_postgres_9_0(table_name)
|
77
|
+
# roses are red
|
78
|
+
# violets are blue
|
79
|
+
# and this is the most boring piece of code ever
|
80
|
+
history_table = "#{HISTORY_SCHEMA}.#{table_name}"
|
81
|
+
p_pkey = primary_key(table_name)
|
82
|
+
|
83
|
+
execute "ALTER TABLE #{history_table} ADD COLUMN validity tsrange;"
|
84
|
+
execute """
|
85
|
+
UPDATE #{history_table} SET validity = tsrange(valid_from,
|
86
|
+
CASE WHEN extract(year from valid_to) = 9999 THEN NULL
|
87
|
+
ELSE valid_to
|
88
|
+
END
|
89
|
+
);
|
90
|
+
"""
|
91
|
+
|
92
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_from;"
|
93
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_from_and_valid_to;"
|
94
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_to;"
|
95
|
+
execute "DROP INDEX #{history_table}_inherit_pkey"
|
96
|
+
execute "DROP INDEX #{history_table}_recorded_at"
|
97
|
+
execute "DROP INDEX #{history_table}_instance_history"
|
98
|
+
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_valid_from_before_valid_to;"
|
99
|
+
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_timeline_consistency;"
|
100
|
+
execute "DROP RULE #{table_name}_upd_first ON #{table_name};"
|
101
|
+
execute "DROP RULE #{table_name}_upd_next ON #{table_name};"
|
102
|
+
execute "DROP RULE #{table_name}_del ON #{table_name};"
|
103
|
+
execute "DROP RULE #{table_name}_ins ON #{table_name};"
|
104
|
+
execute "DROP TRIGGER history_ins ON #{TEMPORAL_SCHEMA}.#{table_name};"
|
105
|
+
execute "DROP FUNCTION #{TEMPORAL_SCHEMA}.#{table_name}_ins();"
|
106
|
+
execute "ALTER TABLE #{history_table} DROP COLUMN valid_from;"
|
107
|
+
execute "ALTER TABLE #{history_table} DROP COLUMN valid_to;"
|
108
|
+
|
109
|
+
execute "CREATE EXTENSION IF NOT EXISTS btree_gist;"
|
110
|
+
|
111
|
+
chrono_create_view_for(table_name)
|
112
|
+
on_history_schema { add_history_validity_constraint(table_name, p_pkey) }
|
113
|
+
on_history_schema { chrono_create_history_indexes_for(table_name, p_pkey) }
|
114
|
+
end
|
115
|
+
# private
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
module Conversions
|
4
|
+
extend self
|
5
|
+
|
6
|
+
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
|
7
|
+
|
8
|
+
def string_to_utc_time(string)
|
9
|
+
if string =~ ISO_DATETIME
|
10
|
+
usec = $7.nil? ? '000000' : $7.ljust(6, '0') # .1 is .100000, not .000001
|
11
|
+
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, usec.to_i
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def time_to_utc_string(time)
|
16
|
+
[time.to_s(:db), sprintf('%06d', time.usec)].join '.'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
module Json
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def create
|
7
|
+
puts "ChronoModel: WARNING - JSON ops are deprecated. Please migrate to JSONB"
|
8
|
+
|
9
|
+
adapter.execute 'CREATE OR REPLACE LANGUAGE plpythonu'
|
10
|
+
adapter.execute File.read(sql 'json_ops.sql')
|
11
|
+
end
|
12
|
+
|
13
|
+
def drop
|
14
|
+
adapter.execute File.read(sql 'uninstall-json_ops.sql')
|
15
|
+
adapter.execute 'DROP LANGUAGE IF EXISTS plpythonu'
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
def sql(file)
|
20
|
+
File.dirname(__FILE__) + '/../../sql/' + file
|
21
|
+
end
|
22
|
+
|
23
|
+
def adapter
|
24
|
+
ActiveRecord::Base.connection
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
data/lib/chrono_model/patches.rb
CHANGED
@@ -1,232 +1,8 @@
|
|
1
|
-
require '
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
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
|
-
# This class supports the AR 5.0 code that expects to receive an
|
23
|
-
# Arel::Table as the left join node. We need to replace the node
|
24
|
-
# with a virtual table that fetches from the history at a given
|
25
|
-
# point in time, we replace the join node with a SqlLiteral node
|
26
|
-
# that does not respond to the methods that AR expects.
|
27
|
-
#
|
28
|
-
# This class provides AR with an object implementing the methods
|
29
|
-
# it expects, yet producing SQL that fetches from history tables
|
30
|
-
# as-of-time.
|
31
|
-
#
|
32
|
-
class JoinNode < Arel::Nodes::SqlLiteral
|
33
|
-
attr_reader :name, :table_name, :table_alias, :as_of_time
|
34
|
-
|
35
|
-
def initialize(join_node, history_model, as_of_time)
|
36
|
-
@name = join_node.table_name
|
37
|
-
@table_name = join_node.table_name
|
38
|
-
@table_alias = join_node.table_alias
|
39
|
-
|
40
|
-
@as_of_time = as_of_time
|
41
|
-
|
42
|
-
virtual_table = history_model.
|
43
|
-
virtual_table_at(@as_of_time, @table_alias || @table_name)
|
44
|
-
|
45
|
-
super(virtual_table)
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
module Relation
|
50
|
-
include AsOfTimeHolder
|
51
|
-
|
52
|
-
def load
|
53
|
-
return super unless @_as_of_time && !loaded?
|
54
|
-
|
55
|
-
super.each {|record| record.as_of_time!(@_as_of_time) }
|
56
|
-
end
|
57
|
-
|
58
|
-
def merge(*)
|
59
|
-
return super unless @_as_of_time
|
60
|
-
|
61
|
-
super.as_of_time!(@_as_of_time)
|
62
|
-
end
|
63
|
-
|
64
|
-
def build_arel(*)
|
65
|
-
return super unless @_as_of_time
|
66
|
-
|
67
|
-
super.tap do |arel|
|
68
|
-
|
69
|
-
arel.join_sources.each do |join|
|
70
|
-
# This case happens with nested includes, where the below
|
71
|
-
# code has already replaced the join.left with a JoinNode.
|
72
|
-
#
|
73
|
-
next if join.left.respond_to?(:as_of_time)
|
74
|
-
|
75
|
-
model = TimeMachine.chrono_models[join.left.table_name]
|
76
|
-
next unless model
|
77
|
-
|
78
|
-
join.left = JoinNode.new(join.left, model.history, @_as_of_time)
|
79
|
-
end
|
80
|
-
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
# Build a preloader at the +as_of_time+ of this relation.
|
85
|
-
# Pass the current model to define Relation
|
86
|
-
#
|
87
|
-
def build_preloader
|
88
|
-
ActiveRecord::Associations::Preloader.new(
|
89
|
-
model: self.model, as_of_time: as_of_time
|
90
|
-
)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
# This class is a dummy relation whose scope is only to pass around the
|
95
|
-
# as_of_time parameters across ActiveRecord call chains.
|
96
|
-
#
|
97
|
-
# With AR 5.2 a simple relation can be used, as the only required argument
|
98
|
-
# is the model. 5.0 and 5.1 require more arguments, that are passed here.
|
99
|
-
#
|
100
|
-
class AsOfTimeRelation < ActiveRecord::Relation
|
101
|
-
if ActiveRecord::VERSION::STRING.to_f < 5.2
|
102
|
-
def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {})
|
103
|
-
super(klass, table, predicate_builder, values)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
# Patches ActiveRecord::Associations::Preloader to add support for
|
109
|
-
# temporal associations. This is tying itself to Rails internals
|
110
|
-
# and it is ugly :-(.
|
111
|
-
#
|
112
|
-
module Preloader
|
113
|
-
attr_reader :options
|
114
|
-
|
115
|
-
# We overwrite the initializer in order to pass the +as_of_time+
|
116
|
-
# parameter above in the build_preloader
|
117
|
-
#
|
118
|
-
def initialize(options = {})
|
119
|
-
@options = options.freeze
|
120
|
-
end
|
121
|
-
|
122
|
-
# Patches the AR Preloader (lib/active_record/associations/preloader.rb)
|
123
|
-
# in order to carry around the +as_of_time+ of the original invocation.
|
124
|
-
#
|
125
|
-
# * The +records+ are the parent records where the association is defined
|
126
|
-
# * The +associations+ are the association names involved in preloading
|
127
|
-
# * The +given_preload_scope+ is the preloading scope, that is used only
|
128
|
-
# in the :through association and it holds the intermediate records
|
129
|
-
# _through_ which the final associated records are eventually fetched.
|
130
|
-
#
|
131
|
-
# As the +preload_scope+ is passed around to all the different
|
132
|
-
# incarnations of the preloader strategies, we are using it to pass
|
133
|
-
# around the +as_of_time+ of the original query invocation, so that
|
134
|
-
# preloaded records are preloaded honoring the +as_of_time+.
|
135
|
-
#
|
136
|
-
# The +preload_scope+ is present only in through associations, but the
|
137
|
-
# preloader interfaces expect it to be always defined, for consistency.
|
138
|
-
#
|
139
|
-
# For `:through` associations, the +given_preload_scope+ is already a
|
140
|
-
# +Relation+, that already has the +as_of_time+ getters and setters,
|
141
|
-
# so we use it directly.
|
142
|
-
#
|
143
|
-
def preload(records, associations, given_preload_scope = nil)
|
144
|
-
if options[:as_of_time]
|
145
|
-
preload_scope = given_preload_scope ||
|
146
|
-
AsOfTimeRelation.new(options[:model])
|
147
|
-
|
148
|
-
preload_scope.as_of_time!(options[:as_of_time])
|
149
|
-
end
|
150
|
-
|
151
|
-
super records, associations, preload_scope
|
152
|
-
end
|
153
|
-
|
154
|
-
module Association
|
155
|
-
# Builds the preloader scope taking into account a potential
|
156
|
-
# +as_of_time+ passed down the call chain starting at the
|
157
|
-
# end user invocation.
|
158
|
-
#
|
159
|
-
def build_scope
|
160
|
-
scope = super
|
161
|
-
|
162
|
-
if preload_scope.try(:as_of_time)
|
163
|
-
scope = scope.as_of(preload_scope.as_of_time)
|
164
|
-
end
|
165
|
-
|
166
|
-
return scope
|
167
|
-
end
|
168
|
-
end
|
169
|
-
end
|
170
|
-
|
171
|
-
# Patches ActiveRecord::Associations::Association to add support for
|
172
|
-
# temporal associations.
|
173
|
-
#
|
174
|
-
# Each record fetched from the +as_of+ scope on the owner class will have
|
175
|
-
# an additional "as_of_time" field yielding the UTC time of the request,
|
176
|
-
# then the as_of scope is called on either this association's class or
|
177
|
-
# on the join model's (:through association) one.
|
178
|
-
#
|
179
|
-
module Association
|
180
|
-
def skip_statement_cache?(*)
|
181
|
-
super || _chrono_target?
|
182
|
-
end
|
183
|
-
|
184
|
-
# If the association class or the through association are ChronoModels,
|
185
|
-
# then fetches the records from a virtual table using a subquery scope
|
186
|
-
# to a specific timestamp.
|
187
|
-
def scope
|
188
|
-
scope = super
|
189
|
-
return scope unless _chrono_record?
|
190
|
-
|
191
|
-
if _chrono_target?
|
192
|
-
# For standard associations, replace the table name with the virtual
|
193
|
-
# as-of table name at the owner's as-of-time
|
194
|
-
#
|
195
|
-
scope = scope.from(klass.history.virtual_table_at(owner.as_of_time))
|
196
|
-
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
197
|
-
|
198
|
-
# For through associations, replace the joined table name instead
|
199
|
-
# with a virtual table that selects records from the history at
|
200
|
-
# the given +as_of_time+.
|
201
|
-
#
|
202
|
-
scope.join_sources.each do |join|
|
203
|
-
if join.left.name == through_reflection.klass.table_name
|
204
|
-
history_model = through_reflection.klass.history
|
205
|
-
|
206
|
-
join.left = JoinNode.new(join.left, history_model, owner.as_of_time)
|
207
|
-
end
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
scope.as_of_time!(owner.as_of_time)
|
212
|
-
|
213
|
-
return scope
|
214
|
-
end
|
215
|
-
|
216
|
-
private
|
217
|
-
def _chrono_record?
|
218
|
-
owner.respond_to?(:as_of_time) && owner.as_of_time.present?
|
219
|
-
end
|
220
|
-
|
221
|
-
def _chrono_target?
|
222
|
-
@_target_klass ||= reflection.options[:polymorphic] ?
|
223
|
-
owner.public_send(reflection.foreign_type).constantize :
|
224
|
-
reflection.klass
|
225
|
-
|
226
|
-
@_target_klass.chrono?
|
227
|
-
end
|
228
|
-
|
229
|
-
end
|
230
|
-
|
231
|
-
end
|
232
|
-
end
|
1
|
+
require 'chrono_model/patches/as_of_time_holder'
|
2
|
+
require 'chrono_model/patches/as_of_time_relation'
|
3
|
+
|
4
|
+
require 'chrono_model/patches/join_node'
|
5
|
+
require 'chrono_model/patches/relation'
|
6
|
+
require 'chrono_model/patches/preloader'
|
7
|
+
require 'chrono_model/patches/association'
|
8
|
+
require 'chrono_model/patches/db_console'
|