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.
Files changed (38) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +19 -14
  3. data/README.md +49 -25
  4. data/lib/chrono_model.rb +37 -3
  5. data/lib/chrono_model/adapter.rb +91 -874
  6. data/lib/chrono_model/adapter/ddl.rb +225 -0
  7. data/lib/chrono_model/adapter/indexes.rb +194 -0
  8. data/lib/chrono_model/adapter/migrations.rb +282 -0
  9. data/lib/chrono_model/adapter/tsrange.rb +57 -0
  10. data/lib/chrono_model/adapter/upgrade.rb +120 -0
  11. data/lib/chrono_model/conversions.rb +20 -0
  12. data/lib/chrono_model/json.rb +28 -0
  13. data/lib/chrono_model/patches.rb +8 -232
  14. data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
  15. data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
  16. data/lib/chrono_model/patches/association.rb +52 -0
  17. data/lib/chrono_model/patches/db_console.rb +11 -0
  18. data/lib/chrono_model/patches/join_node.rb +32 -0
  19. data/lib/chrono_model/patches/preloader.rb +68 -0
  20. data/lib/chrono_model/patches/relation.rb +58 -0
  21. data/lib/chrono_model/time_gate.rb +5 -5
  22. data/lib/chrono_model/time_machine.rb +47 -427
  23. data/lib/chrono_model/time_machine/history_model.rb +196 -0
  24. data/lib/chrono_model/time_machine/time_query.rb +86 -0
  25. data/lib/chrono_model/time_machine/timeline.rb +94 -0
  26. data/lib/chrono_model/utilities.rb +27 -0
  27. data/lib/chrono_model/version.rb +1 -1
  28. data/spec/aruba/dbconsole_spec.rb +25 -0
  29. data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
  30. data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
  31. data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
  32. data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
  33. data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
  34. data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
  35. data/spec/config.travis.yml +1 -0
  36. data/spec/config.yml.example +1 -0
  37. metadata +35 -14
  38. data/lib/chrono_model/utils.rb +0 -117
@@ -0,0 +1,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