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