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,225 @@
|
|
1
|
+
require 'multi_json'
|
2
|
+
|
3
|
+
module ChronoModel
|
4
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
5
|
+
|
6
|
+
module DDL
|
7
|
+
private
|
8
|
+
# Create the public view and its INSTEAD OF triggers
|
9
|
+
#
|
10
|
+
def chrono_public_view_ddl(table, options = nil)
|
11
|
+
pk = primary_key(table)
|
12
|
+
current = [TEMPORAL_SCHEMA, table].join('.')
|
13
|
+
history = [HISTORY_SCHEMA, table].join('.')
|
14
|
+
|
15
|
+
options ||= chrono_metadata_for(table)
|
16
|
+
|
17
|
+
# SELECT - return only current data
|
18
|
+
#
|
19
|
+
execute "DROP VIEW #{table}" if data_source_exists? table
|
20
|
+
execute "CREATE VIEW #{table} AS SELECT * FROM ONLY #{current}"
|
21
|
+
|
22
|
+
chrono_metadata_set(table, options.merge(chronomodel: VERSION))
|
23
|
+
|
24
|
+
# Set default values on the view (closes #12)
|
25
|
+
#
|
26
|
+
columns(table).each do |column|
|
27
|
+
default = if column.default.nil?
|
28
|
+
column.default_function
|
29
|
+
else
|
30
|
+
quote(column.default)
|
31
|
+
end
|
32
|
+
|
33
|
+
next if column.name == pk || default.nil?
|
34
|
+
|
35
|
+
execute "ALTER VIEW #{table} ALTER COLUMN #{quote_column_name(column.name)} SET DEFAULT #{default}"
|
36
|
+
end
|
37
|
+
|
38
|
+
columns = self.columns(table).map {|c| quote_column_name(c.name)}
|
39
|
+
columns.delete(quote_column_name(pk))
|
40
|
+
|
41
|
+
fields, values = columns.join(', '), columns.map {|c| "NEW.#{c}"}.join(', ')
|
42
|
+
|
43
|
+
chrono_create_INSERT_trigger(table, pk, current, history, fields, values)
|
44
|
+
chrono_create_UPDATE_trigger(table, pk, current, history, fields, values, options, columns)
|
45
|
+
chrono_create_DELETE_trigger(table, pk, current, history)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Create the history table in the history schema
|
49
|
+
def chrono_history_table_ddl(table)
|
50
|
+
parent = "#{TEMPORAL_SCHEMA}.#{table}"
|
51
|
+
p_pkey = primary_key(parent)
|
52
|
+
|
53
|
+
execute <<-SQL
|
54
|
+
CREATE TABLE #{table} (
|
55
|
+
hid SERIAL PRIMARY KEY,
|
56
|
+
validity tsrange NOT NULL,
|
57
|
+
recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now())
|
58
|
+
) INHERITS ( #{parent} )
|
59
|
+
SQL
|
60
|
+
|
61
|
+
add_history_validity_constraint(table, p_pkey)
|
62
|
+
|
63
|
+
chrono_create_history_indexes_for(table, p_pkey)
|
64
|
+
end
|
65
|
+
|
66
|
+
def add_history_validity_constraint(table, pkey)
|
67
|
+
add_timeline_consistency_constraint(table, :validity, id: pkey, on_current_schema: true)
|
68
|
+
end
|
69
|
+
|
70
|
+
def remove_history_validity_constraint(table, options = {})
|
71
|
+
remove_timeline_consistency_constraint(table, options.merge(on_current_schema: true))
|
72
|
+
end
|
73
|
+
|
74
|
+
# INSERT - insert data both in the temporal table and in the history one.
|
75
|
+
#
|
76
|
+
# The serial sequence is invoked manually only if the PK is NULL, to
|
77
|
+
# allow setting the PK to a specific value (think migration scenario).
|
78
|
+
#
|
79
|
+
def chrono_create_INSERT_trigger(table, pk, current, history, fields, values)
|
80
|
+
seq = serial_sequence(current, pk)
|
81
|
+
|
82
|
+
execute <<-SQL
|
83
|
+
CREATE OR REPLACE FUNCTION chronomodel_#{table}_insert() RETURNS TRIGGER AS $$
|
84
|
+
BEGIN
|
85
|
+
IF NEW.#{pk} IS NULL THEN
|
86
|
+
NEW.#{pk} := nextval('#{seq}');
|
87
|
+
END IF;
|
88
|
+
|
89
|
+
INSERT INTO #{current} ( #{pk}, #{fields} )
|
90
|
+
VALUES ( NEW.#{pk}, #{values} );
|
91
|
+
|
92
|
+
INSERT INTO #{history} ( #{pk}, #{fields}, validity )
|
93
|
+
VALUES ( NEW.#{pk}, #{values}, tsrange(timezone('UTC', now()), NULL) );
|
94
|
+
|
95
|
+
RETURN NEW;
|
96
|
+
END;
|
97
|
+
$$ LANGUAGE plpgsql;
|
98
|
+
|
99
|
+
DROP TRIGGER IF EXISTS chronomodel_insert ON #{table};
|
100
|
+
|
101
|
+
CREATE TRIGGER chronomodel_insert INSTEAD OF INSERT ON #{table}
|
102
|
+
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_insert();
|
103
|
+
SQL
|
104
|
+
end
|
105
|
+
|
106
|
+
# UPDATE - set the last history entry validity to now, save the current data
|
107
|
+
# in a new history entry and update the temporal table with the new data.
|
108
|
+
#
|
109
|
+
# If there are no changes, this trigger suppresses redundant updates.
|
110
|
+
#
|
111
|
+
# If a row in the history with the current ID and current timestamp already
|
112
|
+
# exists, update it with new data. This logic makes possible to "squash"
|
113
|
+
# together changes made in a transaction in a single history row.
|
114
|
+
#
|
115
|
+
# If you want to disable this behaviour, set the CHRONOMODEL_NO_SQUASH
|
116
|
+
# environment variable. This is useful when running scenarios inside
|
117
|
+
# cucumber, in which everything runs in the same transaction.
|
118
|
+
#
|
119
|
+
def chrono_create_UPDATE_trigger(table, pk, current, history, fields, values, options, columns)
|
120
|
+
# Columns to be journaled. By default everything except updated_at (GH #7)
|
121
|
+
#
|
122
|
+
journal = if options[:journal]
|
123
|
+
options[:journal].map {|col| quote_column_name(col)}
|
124
|
+
|
125
|
+
elsif options[:no_journal]
|
126
|
+
columns - options[:no_journal].map {|col| quote_column_name(col)}
|
127
|
+
|
128
|
+
elsif options[:full_journal]
|
129
|
+
columns
|
130
|
+
|
131
|
+
else
|
132
|
+
columns - [ quote_column_name('updated_at') ]
|
133
|
+
end
|
134
|
+
|
135
|
+
journal &= columns
|
136
|
+
|
137
|
+
execute <<-SQL
|
138
|
+
CREATE OR REPLACE FUNCTION chronomodel_#{table}_update() RETURNS TRIGGER AS $$
|
139
|
+
DECLARE _now timestamp;
|
140
|
+
DECLARE _hid integer;
|
141
|
+
DECLARE _old record;
|
142
|
+
DECLARE _new record;
|
143
|
+
BEGIN
|
144
|
+
IF OLD IS NOT DISTINCT FROM NEW THEN
|
145
|
+
RETURN NULL;
|
146
|
+
END IF;
|
147
|
+
|
148
|
+
_old := row(#{journal.map {|c| "OLD.#{c}" }.join(', ')});
|
149
|
+
_new := row(#{journal.map {|c| "NEW.#{c}" }.join(', ')});
|
150
|
+
|
151
|
+
IF _old IS NOT DISTINCT FROM _new THEN
|
152
|
+
UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
|
153
|
+
RETURN NEW;
|
154
|
+
END IF;
|
155
|
+
|
156
|
+
_now := timezone('UTC', now());
|
157
|
+
_hid := NULL;
|
158
|
+
|
159
|
+
#{"SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;" unless ENV['CHRONOMODEL_NO_SQUASH']}
|
160
|
+
|
161
|
+
IF _hid IS NOT NULL THEN
|
162
|
+
UPDATE #{history} SET ( #{fields} ) = ( #{values} ) WHERE hid = _hid;
|
163
|
+
ELSE
|
164
|
+
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
165
|
+
WHERE #{pk} = OLD.#{pk} AND upper_inf(validity);
|
166
|
+
|
167
|
+
INSERT INTO #{history} ( #{pk}, #{fields}, validity )
|
168
|
+
VALUES ( OLD.#{pk}, #{values}, tsrange(_now, NULL) );
|
169
|
+
END IF;
|
170
|
+
|
171
|
+
UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
|
172
|
+
|
173
|
+
RETURN NEW;
|
174
|
+
END;
|
175
|
+
$$ LANGUAGE plpgsql;
|
176
|
+
|
177
|
+
DROP TRIGGER IF EXISTS chronomodel_update ON #{table};
|
178
|
+
|
179
|
+
CREATE TRIGGER chronomodel_update INSTEAD OF UPDATE ON #{table}
|
180
|
+
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_update();
|
181
|
+
SQL
|
182
|
+
end
|
183
|
+
|
184
|
+
# DELETE - save the current data in the history and eventually delete the
|
185
|
+
# data from the temporal table.
|
186
|
+
# The first DELETE is required to remove history for records INSERTed and
|
187
|
+
# DELETEd in the same transaction.
|
188
|
+
#
|
189
|
+
def chrono_create_DELETE_trigger(table, pk, current, history)
|
190
|
+
execute <<-SQL
|
191
|
+
CREATE OR REPLACE FUNCTION chronomodel_#{table}_delete() RETURNS TRIGGER AS $$
|
192
|
+
DECLARE _now timestamp;
|
193
|
+
BEGIN
|
194
|
+
_now := timezone('UTC', now());
|
195
|
+
|
196
|
+
DELETE FROM #{history}
|
197
|
+
WHERE #{pk} = old.#{pk} AND validity = tsrange(_now, NULL);
|
198
|
+
|
199
|
+
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
200
|
+
WHERE #{pk} = old.#{pk} AND upper_inf(validity);
|
201
|
+
|
202
|
+
DELETE FROM ONLY #{current}
|
203
|
+
WHERE #{pk} = old.#{pk};
|
204
|
+
|
205
|
+
RETURN OLD;
|
206
|
+
END;
|
207
|
+
$$ LANGUAGE plpgsql;
|
208
|
+
|
209
|
+
DROP TRIGGER IF EXISTS chronomodel_delete ON #{table};
|
210
|
+
|
211
|
+
CREATE TRIGGER chronomodel_delete INSTEAD OF DELETE ON #{table}
|
212
|
+
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_delete();
|
213
|
+
SQL
|
214
|
+
end
|
215
|
+
|
216
|
+
def chrono_drop_trigger_functions_for(table_name)
|
217
|
+
%w( insert update delete ).each do |func|
|
218
|
+
execute "DROP FUNCTION IF EXISTS chronomodel_#{table_name}_#{func}()"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
# private
|
222
|
+
end
|
223
|
+
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module Indexes
|
5
|
+
# Create temporal indexes for timestamp search.
|
6
|
+
#
|
7
|
+
# This index is used by +TimeMachine.at+, `.current` and `.past` to
|
8
|
+
# build the temporal WHERE clauses that fetch the state of records at
|
9
|
+
# a single point in time.
|
10
|
+
#
|
11
|
+
# Parameters:
|
12
|
+
#
|
13
|
+
# `table`: the table where to create indexes on
|
14
|
+
# `range`: the tsrange field
|
15
|
+
#
|
16
|
+
# Options:
|
17
|
+
#
|
18
|
+
# `:name`: the index name prefix, defaults to
|
19
|
+
# index_{table}_temporal_on_{range / lower_range / upper_range}
|
20
|
+
#
|
21
|
+
def add_temporal_indexes(table, range, options = {})
|
22
|
+
range_idx, lower_idx, upper_idx =
|
23
|
+
temporal_index_names(table, range, options)
|
24
|
+
|
25
|
+
chrono_alter_index(table, options) do
|
26
|
+
execute <<-SQL
|
27
|
+
CREATE INDEX #{range_idx} ON #{table} USING gist ( #{range} )
|
28
|
+
SQL
|
29
|
+
|
30
|
+
# Indexes used for precise history filtering, sorting and, in history
|
31
|
+
# tables, by UPDATE / DELETE triggers.
|
32
|
+
#
|
33
|
+
execute "CREATE INDEX #{lower_idx} ON #{table} ( lower(#{range}) )"
|
34
|
+
execute "CREATE INDEX #{upper_idx} ON #{table} ( upper(#{range}) )"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def remove_temporal_indexes(table, range, options = {})
|
39
|
+
indexes = temporal_index_names(table, range, options)
|
40
|
+
|
41
|
+
chrono_alter_index(table, options) do
|
42
|
+
indexes.each {|idx| execute "DROP INDEX #{idx}" }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Adds an EXCLUDE constraint to the given table, to assure that
|
47
|
+
# no more than one record can occupy a definite segment on a
|
48
|
+
# timeline.
|
49
|
+
#
|
50
|
+
def add_timeline_consistency_constraint(table, range, options = {})
|
51
|
+
name = timeline_consistency_constraint_name(table)
|
52
|
+
id = options[:id] || primary_key(table)
|
53
|
+
|
54
|
+
chrono_alter_constraint(table, options) do
|
55
|
+
execute <<-SQL
|
56
|
+
ALTER TABLE #{table} ADD CONSTRAINT #{name}
|
57
|
+
EXCLUDE USING gist ( #{id} WITH =, #{range} WITH && )
|
58
|
+
SQL
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def remove_timeline_consistency_constraint(table, options = {})
|
63
|
+
name = timeline_consistency_constraint_name(table)
|
64
|
+
|
65
|
+
chrono_alter_constraint(table, options) do
|
66
|
+
execute <<-SQL
|
67
|
+
ALTER TABLE #{table} DROP CONSTRAINT #{name}
|
68
|
+
SQL
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def timeline_consistency_constraint_name(table)
|
73
|
+
"#{table}_timeline_consistency"
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
# Creates indexes for a newly made history table
|
78
|
+
#
|
79
|
+
def chrono_create_history_indexes_for(table, p_pkey)
|
80
|
+
add_temporal_indexes table, :validity, on_current_schema: true
|
81
|
+
|
82
|
+
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
|
83
|
+
execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
|
84
|
+
execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
|
85
|
+
end
|
86
|
+
|
87
|
+
# Rename indexes on history schema
|
88
|
+
#
|
89
|
+
def chrono_rename_history_indexes(name, new_name)
|
90
|
+
on_history_schema do
|
91
|
+
standard_index_names = %w(
|
92
|
+
inherit_pkey instance_history pkey
|
93
|
+
recorded_at timeline_consistency )
|
94
|
+
|
95
|
+
old_names = temporal_index_names(name, :validity) +
|
96
|
+
standard_index_names.map {|i| [name, i].join('_') }
|
97
|
+
|
98
|
+
new_names = temporal_index_names(new_name, :validity) +
|
99
|
+
standard_index_names.map {|i| [new_name, i].join('_') }
|
100
|
+
|
101
|
+
old_names.zip(new_names).each do |old, new|
|
102
|
+
execute "ALTER INDEX #{old} RENAME TO #{new}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Rename indexes on temporal schema
|
108
|
+
#
|
109
|
+
def chrono_rename_temporal_indexes(name, new_name)
|
110
|
+
on_temporal_schema do
|
111
|
+
temporal_indexes = indexes(new_name)
|
112
|
+
temporal_indexes.map(&:name).each do |old_idx_name|
|
113
|
+
if old_idx_name =~ /^index_#{name}_on_(?<columns>.+)/
|
114
|
+
new_idx_name = "index_#{new_name}_on_#{$~['columns']}"
|
115
|
+
execute "ALTER INDEX #{old_idx_name} RENAME TO #{new_idx_name}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Copy the indexes from the temporal table to the history table
|
122
|
+
# if the indexes are not already created with the same name.
|
123
|
+
#
|
124
|
+
# Uniqueness is voluntarily ignored, as it doesn't make sense on
|
125
|
+
# history tables.
|
126
|
+
#
|
127
|
+
# Used in migrations.
|
128
|
+
#
|
129
|
+
# Ref: GitHub pull #21.
|
130
|
+
#
|
131
|
+
def chrono_copy_indexes_to_history(table_name)
|
132
|
+
history_indexes = on_history_schema { indexes(table_name) }.map(&:name)
|
133
|
+
temporal_indexes = on_temporal_schema { indexes(table_name) }
|
134
|
+
|
135
|
+
temporal_indexes.each do |index|
|
136
|
+
next if history_indexes.include?(index.name)
|
137
|
+
|
138
|
+
on_history_schema do
|
139
|
+
execute %[
|
140
|
+
CREATE INDEX #{index.name} ON #{table_name}
|
141
|
+
USING #{index.using} ( #{index.columns.join(', ')} )
|
142
|
+
], 'Copy index from temporal to history'
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Returns a suitable index name on the given table and for the
|
148
|
+
# given range definition.
|
149
|
+
#
|
150
|
+
def temporal_index_names(table, range, options = {})
|
151
|
+
prefix = options[:name].presence || "index_#{table}_temporal"
|
152
|
+
|
153
|
+
# When creating computed indexes
|
154
|
+
#
|
155
|
+
# e.g. ends_on::timestamp + time '23:59:59'
|
156
|
+
#
|
157
|
+
# remove everything following the field name.
|
158
|
+
range = range.to_s.sub(/\W.*/, '')
|
159
|
+
|
160
|
+
[range, "lower_#{range}", "upper_#{range}"].map do |suffix|
|
161
|
+
[prefix, 'on', suffix].join('_')
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# Generic alteration of history tables, where changes have to be
|
166
|
+
# propagated both on the temporal table and the history one.
|
167
|
+
#
|
168
|
+
# Internally, the :on_current_schema bypasses the +is_chrono?+
|
169
|
+
# check, as some temporal indexes and constraints are created
|
170
|
+
# only on the history table, and the creation methods already
|
171
|
+
# run scoped into the correct schema.
|
172
|
+
#
|
173
|
+
def chrono_alter_index(table_name, options)
|
174
|
+
if is_chrono?(table_name) && !options[:on_current_schema]
|
175
|
+
on_temporal_schema { yield }
|
176
|
+
on_history_schema { yield }
|
177
|
+
else
|
178
|
+
yield
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def chrono_alter_constraint(table_name, options)
|
183
|
+
if is_chrono?(table_name) && !options[:on_current_schema]
|
184
|
+
on_temporal_schema { yield }
|
185
|
+
else
|
186
|
+
yield
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
|
191
|
+
end
|
192
|
+
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,282 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module Migrations
|
5
|
+
# Creates the given table, possibly creating the temporal schema
|
6
|
+
# objects if the `:temporal` option is given and set to true.
|
7
|
+
#
|
8
|
+
def create_table(table_name, options = {})
|
9
|
+
# No temporal features requested, skip
|
10
|
+
return super unless options[:temporal]
|
11
|
+
|
12
|
+
if options[:id] == false
|
13
|
+
logger.warn "ChronoModel: Temporal Temporal tables require a primary key."
|
14
|
+
logger.warn "ChronoModel: Adding a `__chrono_id' primary key to #{table_name} definition."
|
15
|
+
|
16
|
+
options[:id] = '__chrono_id'
|
17
|
+
end
|
18
|
+
|
19
|
+
transaction do
|
20
|
+
on_temporal_schema { super }
|
21
|
+
on_history_schema { chrono_history_table_ddl(table_name) }
|
22
|
+
|
23
|
+
chrono_public_view_ddl(table_name, options)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# If renaming a temporal table, rename the history and view as well.
|
28
|
+
#
|
29
|
+
def rename_table(name, new_name)
|
30
|
+
return super unless is_chrono?(name)
|
31
|
+
|
32
|
+
clear_cache!
|
33
|
+
|
34
|
+
transaction do
|
35
|
+
# Rename tables
|
36
|
+
#
|
37
|
+
on_temporal_schema { rename_table_and_pk(name, new_name) }
|
38
|
+
on_history_schema { rename_table_and_pk(name, new_name) }
|
39
|
+
|
40
|
+
# Rename indexes
|
41
|
+
#
|
42
|
+
chrono_rename_history_indexes(name, new_name)
|
43
|
+
chrono_rename_temporal_indexes(name, new_name)
|
44
|
+
|
45
|
+
# Drop view
|
46
|
+
#
|
47
|
+
execute "DROP VIEW #{name}"
|
48
|
+
|
49
|
+
# Drop functions
|
50
|
+
#
|
51
|
+
chrono_drop_trigger_functions_for(name)
|
52
|
+
|
53
|
+
# Create view and functions
|
54
|
+
#
|
55
|
+
chrono_public_view_ddl(new_name)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# If changing a temporal table, redirect the change to the table in the
|
60
|
+
# temporal schema and recreate views.
|
61
|
+
#
|
62
|
+
# If the `:temporal` option is specified, enables or disables temporal
|
63
|
+
# features on the given table. Please note that you'll lose your history
|
64
|
+
# when demoting a temporal table to a plain one.
|
65
|
+
#
|
66
|
+
def change_table(table_name, options = {}, &block)
|
67
|
+
transaction do
|
68
|
+
|
69
|
+
# Add an empty proc to support calling change_table without a block.
|
70
|
+
#
|
71
|
+
block ||= proc { }
|
72
|
+
|
73
|
+
case options[:temporal]
|
74
|
+
when true
|
75
|
+
if !is_chrono?(table_name)
|
76
|
+
chrono_make_temporal_table(table_name, options)
|
77
|
+
end
|
78
|
+
|
79
|
+
drop_and_recreate_public_view(table_name, options) do
|
80
|
+
super table_name, options, &block
|
81
|
+
end
|
82
|
+
|
83
|
+
when false
|
84
|
+
if is_chrono?(table_name)
|
85
|
+
chrono_undo_temporal_table(table_name)
|
86
|
+
end
|
87
|
+
|
88
|
+
super table_name, options, &block
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# If dropping a temporal table, drops it from the temporal schema
|
95
|
+
# adding the CASCADE option so to delete the history, view and triggers.
|
96
|
+
#
|
97
|
+
def drop_table(table_name, *)
|
98
|
+
return super unless is_chrono?(table_name)
|
99
|
+
|
100
|
+
on_temporal_schema { execute "DROP TABLE #{table_name} CASCADE" }
|
101
|
+
|
102
|
+
chrono_drop_trigger_functions_for(table_name)
|
103
|
+
end
|
104
|
+
|
105
|
+
# If adding an index to a temporal table, add it to the one in the
|
106
|
+
# temporal schema and to the history one. If the `:unique` option is
|
107
|
+
# present, it is removed from the index created in the history table.
|
108
|
+
#
|
109
|
+
def add_index(table_name, column_name, options = {})
|
110
|
+
return super unless is_chrono?(table_name)
|
111
|
+
|
112
|
+
transaction do
|
113
|
+
on_temporal_schema { super }
|
114
|
+
|
115
|
+
# Uniqueness constraints do not make sense in the history table
|
116
|
+
options = options.dup.tap {|o| o.delete(:unique)} if options[:unique].present?
|
117
|
+
|
118
|
+
on_history_schema { super table_name, column_name, options }
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# If removing an index from a temporal table, remove it both from the
|
123
|
+
# temporal and the history schemas.
|
124
|
+
#
|
125
|
+
def remove_index(table_name, *)
|
126
|
+
return super unless is_chrono?(table_name)
|
127
|
+
|
128
|
+
transaction do
|
129
|
+
on_temporal_schema { super }
|
130
|
+
on_history_schema { super }
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# If adding a column to a temporal table, creates it in the table in
|
135
|
+
# the temporal schema and updates the triggers.
|
136
|
+
#
|
137
|
+
def add_column(table_name, *)
|
138
|
+
return super unless is_chrono?(table_name)
|
139
|
+
|
140
|
+
transaction do
|
141
|
+
# Add the column to the temporal table
|
142
|
+
on_temporal_schema { super }
|
143
|
+
|
144
|
+
# Update the triggers
|
145
|
+
chrono_public_view_ddl(table_name)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# If renaming a column of a temporal table, rename it in the table in
|
150
|
+
# the temporal schema and update the triggers.
|
151
|
+
#
|
152
|
+
def rename_column(table_name, *)
|
153
|
+
return super unless is_chrono?(table_name)
|
154
|
+
|
155
|
+
# Rename the column in the temporal table and in the view
|
156
|
+
transaction do
|
157
|
+
on_temporal_schema { super }
|
158
|
+
super
|
159
|
+
|
160
|
+
# Update the triggers
|
161
|
+
chrono_public_view_ddl(table_name)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# If removing a column from a temporal table, we are forced to drop the
|
166
|
+
# view, then change the column from the table in the temporal schema and
|
167
|
+
# eventually recreate the triggers.
|
168
|
+
#
|
169
|
+
def change_column(table_name, *)
|
170
|
+
return super unless is_chrono?(table_name)
|
171
|
+
drop_and_recreate_public_view(table_name) { super }
|
172
|
+
end
|
173
|
+
|
174
|
+
# Change the default on the temporal schema table.
|
175
|
+
#
|
176
|
+
def change_column_default(table_name, *)
|
177
|
+
return super unless is_chrono?(table_name)
|
178
|
+
on_temporal_schema { super }
|
179
|
+
end
|
180
|
+
|
181
|
+
# Change the null constraint on the temporal schema table.
|
182
|
+
#
|
183
|
+
def change_column_null(table_name, *)
|
184
|
+
return super unless is_chrono?(table_name)
|
185
|
+
on_temporal_schema { super }
|
186
|
+
end
|
187
|
+
|
188
|
+
# If removing a column from a temporal table, we are forced to drop the
|
189
|
+
# view, then drop the column from the table in the temporal schema and
|
190
|
+
# eventually recreate the triggers.
|
191
|
+
#
|
192
|
+
def remove_column(table_name, *)
|
193
|
+
return super unless is_chrono?(table_name)
|
194
|
+
drop_and_recreate_public_view(table_name) { super }
|
195
|
+
end
|
196
|
+
|
197
|
+
private
|
198
|
+
# In destructive changes, such as removing columns or changing column
|
199
|
+
# types, the view must be dropped and recreated, while the change has
|
200
|
+
# to be applied to the table in the temporal schema.
|
201
|
+
#
|
202
|
+
def drop_and_recreate_public_view(table_name, opts = {})
|
203
|
+
transaction do
|
204
|
+
options = chrono_metadata_for(table_name).merge(opts)
|
205
|
+
|
206
|
+
execute "DROP VIEW #{table_name}"
|
207
|
+
|
208
|
+
on_temporal_schema { yield }
|
209
|
+
|
210
|
+
# Recreate the triggers
|
211
|
+
chrono_public_view_ddl(table_name, options)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def chrono_make_temporal_table(table_name, options)
|
216
|
+
# Add temporal features to this table
|
217
|
+
#
|
218
|
+
if !primary_key(table_name)
|
219
|
+
execute "ALTER TABLE #{table_name} ADD __chrono_id SERIAL PRIMARY KEY"
|
220
|
+
end
|
221
|
+
|
222
|
+
execute "ALTER TABLE #{table_name} SET SCHEMA #{TEMPORAL_SCHEMA}"
|
223
|
+
on_history_schema { chrono_history_table_ddl(table_name) }
|
224
|
+
chrono_public_view_ddl(table_name, options)
|
225
|
+
chrono_copy_indexes_to_history(table_name)
|
226
|
+
|
227
|
+
# Optionally copy the plain table data, setting up history
|
228
|
+
# retroactively.
|
229
|
+
#
|
230
|
+
if options[:copy_data]
|
231
|
+
chrono_copy_temporal_to_history(table_name, options)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def chrono_copy_temporal_to_history(table_name, options)
|
236
|
+
seq = on_history_schema { serial_sequence(table_name, primary_key(table_name)) }
|
237
|
+
from = options[:validity] || '0001-01-01 00:00:00'
|
238
|
+
|
239
|
+
execute %[
|
240
|
+
INSERT INTO #{HISTORY_SCHEMA}.#{table_name}
|
241
|
+
SELECT *,
|
242
|
+
nextval('#{seq}') AS hid,
|
243
|
+
tsrange('#{from}', NULL) AS validity,
|
244
|
+
timezone('UTC', now()) AS recorded_at
|
245
|
+
FROM #{TEMPORAL_SCHEMA}.#{table_name}
|
246
|
+
]
|
247
|
+
end
|
248
|
+
|
249
|
+
# Removes temporal features from this table
|
250
|
+
#
|
251
|
+
def chrono_undo_temporal_table(table_name)
|
252
|
+
execute "DROP VIEW #{table_name}"
|
253
|
+
|
254
|
+
chrono_drop_trigger_functions_for(table_name)
|
255
|
+
|
256
|
+
on_history_schema { execute "DROP TABLE #{table_name}" }
|
257
|
+
|
258
|
+
default_schema = select_value 'SELECT current_schema()'
|
259
|
+
on_temporal_schema do
|
260
|
+
if primary_key(table_name) == '__chrono_id'
|
261
|
+
execute "ALTER TABLE #{table_name} DROP __chrono_id"
|
262
|
+
end
|
263
|
+
|
264
|
+
execute "ALTER TABLE #{table_name} SET SCHEMA #{default_schema}"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Renames a table and its primary key sequence name
|
269
|
+
#
|
270
|
+
def rename_table_and_pk(name, new_name)
|
271
|
+
seq = serial_sequence(name, primary_key(name))
|
272
|
+
new_seq = seq.sub(name.to_s, new_name.to_s).split('.').last
|
273
|
+
|
274
|
+
execute "ALTER SEQUENCE #{seq} RENAME TO #{new_seq}"
|
275
|
+
execute "ALTER TABLE #{name} RENAME TO #{new_name}"
|
276
|
+
end
|
277
|
+
|
278
|
+
# private
|
279
|
+
end
|
280
|
+
|
281
|
+
end
|
282
|
+
end
|