chrono_model 0.5.3 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.sql CHANGED
@@ -9,8 +9,9 @@ create schema history; -- schema containing all history tables
9
9
  -- Current countries data - nothing special
10
10
  --
11
11
  create table temporal.countries (
12
- id serial primary key,
13
- name varchar
12
+ id serial primary key,
13
+ name varchar,
14
+ updated_at timestamptz
14
15
  );
15
16
 
16
17
  -- Countries historical data.
@@ -20,128 +21,141 @@ create table temporal.countries (
20
21
  -- http://www.postgresql.org/docs/9.0/static/ddl-inherit.html#DDL-INHERIT-CAVEATS
21
22
  --
22
23
  create table history.countries (
23
-
24
24
  hid serial primary key,
25
- valid_from timestamp not null,
26
- valid_to timestamp not null default '9999-12-31',
25
+ validity tsrange,
27
26
  recorded_at timestamp not null default timezone('UTC', now()),
28
27
 
29
- constraint from_before_to check (valid_from < valid_to),
28
+ constraint overlapping_times exclude using gist ( id with =, validity with && )
30
29
 
31
- constraint overlapping_times exclude using gist (
32
- box(
33
- point( date_part( 'epoch', valid_from), id ),
34
- point( date_part( 'epoch', valid_to - interval '1 millisecond'), id )
35
- ) with &&
36
- )
37
30
  ) inherits ( temporal.countries );
38
31
 
39
32
  -- Inherited primary key
40
- create index country_inherit_pkey on history.countries ( id )
33
+ create index country_inherit_pkey on history.countries ( id );
41
34
 
42
35
  -- Snapshot of data at a specific point in time
43
- create index country_snapshot on history.countries USING gist (
44
- box(
45
- point( date_part( 'epoch', valid_from ), 0 ),
46
- point( date_part( 'epoch', valid_to ), 0 )
47
- )
48
- )
49
-
50
- -- Used by the rules queries when UPDATE'ing and DELETE'ing
51
- create index country_valid_from on history.countries ( valid_from )
52
- create index country_valid_to on history.countries ( valid_from )
53
- create index country_recorded_at on history.countries ( id, valid_to )
36
+ create index country_snapshot on history.countries USING gist ( validity );
37
+
38
+ -- Used by the trigger functions when UPDATE'ing and DELETE'ing
39
+ create index country_lower_validity on history.countries ( lower(validity) )
40
+ create index country_upper_validity on history.countries ( upper(validity) )
41
+ create index country_recorded_at on history.countries ( id, valid_to )
54
42
 
55
43
  -- Single instance whole history
56
44
  create index country_instance_history on history.countries ( id, recorded_at )
57
45
 
58
46
 
59
- -- The countries view, what the Rails' application ORM will actually CRUD on, and
60
- -- the core of the temporal updates.
47
+ -- The countries view, what the Rails' application ORM will actually CRUD on,
48
+ -- and the entry point of the temporal triggers.
61
49
  --
62
50
  -- SELECT - return only current data
63
51
  --
64
- create view public.countries as select *, xmin as __xid from only temporal.countries;
52
+ create view public.countries as select * from only temporal.countries;
65
53
 
66
- -- INSERT - insert data both in the current data table and in the history table.
54
+ -- INSERT - insert data both in the current data table and in the history one.
67
55
  --
68
- -- A trigger is required if there is a serial ID column, as rules by
69
- -- design cannot handle the following case:
70
- --
71
- -- * INSERT INTO ... SELECT: if using currval(), all the rows
72
- -- inserted in the history will have the same identity value;
56
+ create or replace function public.chronomodel_countries_insert() returns trigger as $$
57
+ begin
58
+ if new.id is null then
59
+ new.id = nextval('temporal.countries_id_seq');
60
+ end if;
61
+
62
+ insert into temporal.countries ( id, name, updated_at )
63
+ values ( new.id, new.name, new.updated_at );
64
+
65
+ insert into history.countries (id, name, updated_at, validity )
66
+ values ( new.id, new.name, new.updated_at, tsrange(timezone('utc', now()), null) );
67
+
68
+ return new;
69
+ end;
70
+ $$ language plpgsql;
71
+
72
+ create trigger chronomodel_insert
73
+ instead of insert on public.countries
74
+ for each row execute procedure public.chronomodel_countries_insert();
75
+
76
+ -- UPDATE - set the last history entry validity to now, save the current data
77
+ -- in a new history entry and update the temporal table with the new data.
73
78
  --
74
- -- * if using a separate sequence to solve the above case, it may go
75
- -- out of sync with the main one if an INSERT statement fails due
76
- -- to a table constraint (the first one is nextval()'ed but the
77
- -- nextval() on the history one never happens)
79
+ -- If a row in the history with the current ID and current timestamp already
80
+ -- exists, update it with new data. This logic makes possible to "squash"
81
+ -- together changes made in a transaction in a single history row.
78
82
  --
79
- -- So, only for this case, we resort to an AFTER INSERT FOR EACH ROW trigger.
83
+ -- If the update doesn't change the data, it is skipped and the trigger
84
+ -- returns NULL.
80
85
  --
81
- -- Ref: GH Issue #4.
86
+ -- By default, history is not recorded if only the updated_at field
87
+ -- is changed.
82
88
  --
83
- create rule countries_ins as on insert to public.countries do instead (
89
+ create function chronomodel_countries_update() returns trigger as $$
90
+ declare _now timestamp;
91
+ declare _hid integer;
92
+ declare _old record;
93
+ declare _new record;
94
+ begin
95
+
96
+ if old is not distinct from new then
97
+ return null;
98
+ end if;
84
99
 
85
- insert into temporal.countries ( name ) values ( new.name );
86
- returning ( id, new.name, xmin )
87
- );
100
+ _old := row(old.name);
101
+ _new := row(new.name);
88
102
 
89
- create or replace function temporal.countries_ins() returns trigger as $$
90
- begin
91
- insert into history.countries ( id, name, valid_from )
92
- values ( currval('temporal.countries_id_seq'), new.name, timezone('utc', now()) );
93
- return null;
103
+ if _old is not distinct from new then
104
+ update only temporal.countries set ( name, updated_at ) = ( new.name, new.updated_at ) where id = old.id
105
+ return new;
106
+ end if;
107
+
108
+ _now := timezone('utc', now());
109
+ _hid := null;
110
+
111
+ select hid into _hid from history.countries where id = old.id and lower(validity) = _now;
112
+
113
+ if _hid is not null then
114
+ update history.countries set ( name, updated_at ) = ( new.name ) where hid = _hid;
115
+ else
116
+ update history.countries set validity = tsrange(lower(validity), _now)
117
+ where id = old.id and upper_inf(validity);
118
+
119
+ insert into history.countries ( id, name, updated_at, validity )
120
+ values ( old.id, new.name, new.updated_at, tsrange(_now, null) );
121
+ end if;
122
+
123
+ update only temporal.countries set ( name ) = ( new.name ) where id = old.id;
124
+
125
+ return new;
94
126
  end;
95
127
  $$ language plpgsql;
96
128
 
97
- create trigger history_ins after insert on temporal.countries_ins()
98
- for each row execute procedure temporal.countries_ins();
129
+ create trigger chronomodel_update
130
+ instead of update on temporal.countries
131
+ for each row execute procedure chronomodel_countries_update();
99
132
 
100
- -- UPDATE - set the last history entry validity to now, save the current data in
101
- -- a new history entry and update the current table with the new data.
102
- -- In transactions, create the new history entry only on the first statement,
103
- -- and update the history instead on subsequent ones.
133
+ -- DELETE - save the current data in the history and eventually delete the
134
+ -- data from the temporal table.
104
135
  --
105
- create rule countries_upd_first as on update to countries
106
- where old.__xid::char(10)::int8 <> (txid_current() & (2^32-1)::int8)
107
- do instead (
108
- update history.countries
109
- set valid_to = timezone('UTC', now())
110
- where id = old.id and valid_to = '9999-12-31';
111
-
112
- insert into history.countries ( id, name, valid_from )
113
- values ( old.id, new.name, timezone('UTC', now()) );
114
-
115
- update only temporal.countries
116
- set name = new.name
117
- where id = old.id
118
- );
119
- create rule countries_upd_next as on update to countries do instead (
120
- update history.countries
121
- set name = new.name
122
- where id = old.id and valid_from = timezone('UTC', now())
123
-
124
- update only temporal.countries
125
- set name = new.name
126
- where id = old.id
127
- )
128
-
129
- -- DELETE - save the current data in the history and eventually delete the data
130
- -- from the current table. Special case for records INSERTed and DELETEd in the
131
- -- same transaction - they won't appear at all in history.
136
+ -- The first DELETE is required to remove history for records INSERTed and
137
+ -- DELETEd in the same transaction.
132
138
  --
133
- create rule countries_del as on delete to countries do instead (
134
- delete from history.countries
135
- where id = old.id
136
- and valid_from = timezone('UTC', now())
137
- and valid_to = '9999-12-31'
138
-
139
- update history.countries
140
- set valid_to = now()
141
- where id = old.id and valid_to = '9999-12-31';
142
-
143
- delete from only temporal.countries
144
- where temporal.countries.id = old.id
145
- );
139
+ create or replace function chronomodel_countries_delete() returns trigger as $$
140
+ declare _now timestamp;
141
+ begin
142
+ _now := timezone('utc', now());
143
+
144
+ delete from history.countries
145
+ where id = old.id and validity = tsrange(_now, null);
146
+
147
+ update history.countries set valid_to = _now
148
+ where id = old.id and upper_inf(validity);
149
+
150
+ delete from only temporal.countries
151
+ where temporal.id = old.id;
152
+
153
+ return old;
154
+ end;
155
+ $$ language plpgsql;
156
+
157
+ create trigger chronomodel_delete
158
+ instead of delete on temporal.countries
159
+ for each row execute procedure chronomodel_countries_delete();
146
160
 
147
161
  -- EOF
@@ -2,11 +2,11 @@
2
2
  require File.expand_path('../lib/chrono_model/version', __FILE__)
3
3
 
4
4
  Gem::Specification.new do |gem|
5
- gem.authors = ["Marcello Barnaba"]
6
- gem.email = ["vjt@openssl.it"]
5
+ gem.authors = ['Marcello Barnaba', 'Peter Joseph Brindisi']
6
+ gem.email = ['vjt@openssl.it', 'p.brindisi@ifad.org']
7
7
  gem.description = %q{Give your models as-of date temporal extensions. Built entirely for PostgreSQL >= 9.0}
8
8
  gem.summary = %q{Temporal extensions (SCD Type II) for Active Record}
9
- gem.homepage = "http://github.com/ifad/chronomodel"
9
+ gem.homepage = 'http://github.com/ifad/chronomodel'
10
10
 
11
11
  gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
12
  gem.files = `git ls-files`.split("\n")
@@ -15,6 +15,7 @@ Gem::Specification.new do |gem|
15
15
  gem.require_paths = ["lib"]
16
16
  gem.version = ChronoModel::VERSION
17
17
 
18
- gem.add_dependency "activerecord", "~> 3.2"
18
+ gem.add_dependency "activerecord", "~> 4.0"
19
19
  gem.add_dependency "pg"
20
+ gem.add_dependency "multi_json"
20
21
  end
@@ -0,0 +1,42 @@
1
+ require 'chrono_model'
2
+
3
+ module ActiveRecord
4
+ module ConnectionHandling
5
+
6
+ # Install the new adapter in ActiveRecord. This approach is required because
7
+ # the PG adapter defines +add_column+ itself, thus making impossible to use
8
+ # super() in overridden Module methods.
9
+ #
10
+ def chronomodel_connection(config) # :nodoc:
11
+ conn_params = config.symbolize_keys
12
+
13
+ conn_params.delete_if { |_, v| v.nil? }
14
+
15
+ # Map ActiveRecords param names to PGs.
16
+ conn_params[:user] = conn_params.delete(:username) if conn_params[:username]
17
+ conn_params[:dbname] = conn_params.delete(:database) if conn_params[:database]
18
+
19
+ # Forward only valid config params to PGconn.connect.
20
+ conn_params.keep_if { |k, _| VALID_CONN_PARAMS.include?(k) }
21
+
22
+ # The postgres drivers don't allow the creation of an unconnected PGconn object,
23
+ # so just pass a nil connection object for the time being.
24
+ adapter = ChronoModel::Adapter.new(nil, logger, conn_params, config)
25
+
26
+ unless adapter.chrono_supported?
27
+ raise ChronoModel::Error, "Your database server is not supported by ChronoModel. "\
28
+ "Currently, only PostgreSQL >= 9.3 is supported."
29
+ end
30
+
31
+ adapter.chrono_setup!
32
+
33
+ return adapter
34
+ end
35
+
36
+ module Connectionadapters
37
+ ChronoModelAdapter = ::ChronoModel::Adapter
38
+ end
39
+
40
+ end
41
+ end
42
+
@@ -1,6 +1,5 @@
1
1
  require 'chrono_model/version'
2
2
  require 'chrono_model/adapter'
3
- require 'chrono_model/compatibility'
4
3
  require 'chrono_model/patches'
5
4
  require 'chrono_model/time_machine'
6
5
  require 'chrono_model/time_gate'
@@ -15,14 +14,7 @@ if defined?(Rails)
15
14
  require 'chrono_model/railtie'
16
15
  end
17
16
 
18
- # Install it.
19
17
  silence_warnings do
20
- # Replace AR's PG adapter with the ChronoModel one. This (dirty) approach is
21
- # required because the PG adapter defines +add_column+ itself, thus making
22
- # impossible to use super() in overridden Module methods.
23
- #
24
- ActiveRecord::ConnectionAdapters::PostgreSQLAdapter = ChronoModel::Adapter
25
-
26
18
  # We need to override the "scoped" method on AR::Association for temporal
27
19
  # associations to work as well
28
20
  ActiveRecord::Associations::Association = ChronoModel::Patches::Association
@@ -14,10 +14,15 @@ module ChronoModel
14
14
  # The schema holding historical data
15
15
  HISTORY_SCHEMA = 'history'
16
16
 
17
- # Chronomodel is supported starting with PostgreSQL >= 9.0
17
+ # This is the data type used for the SCD2 validity
18
+ RANGE_TYPE = 'tsrange'
19
+
20
+ # Returns true whether the connection adapter supports our
21
+ # implementation of temporal tables. Currently, Chronomodel
22
+ # is supported starting with PostgreSQL 9.3.
18
23
  #
19
24
  def chrono_supported?
20
- postgresql_version >= 90000
25
+ postgresql_version >= 90300
21
26
  end
22
27
 
23
28
  # Creates the given table, possibly creating the temporal schema
@@ -34,14 +39,11 @@ module ChronoModel
34
39
  options[:id] = '__chrono_id'
35
40
  end
36
41
 
37
- # Create required schemas
38
- chrono_create_schemas!
39
-
40
42
  transaction do
41
43
  _on_temporal_schema { super }
42
44
  _on_history_schema { chrono_create_history_for(table_name) }
43
45
 
44
- chrono_create_view_for(table_name)
46
+ chrono_create_view_for(table_name, options)
45
47
 
46
48
  TableCache.add! table_name
47
49
  end
@@ -55,6 +57,8 @@ module ChronoModel
55
57
  clear_cache!
56
58
 
57
59
  transaction do
60
+ # Rename tables
61
+ #
58
62
  [TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
59
63
  on_schema(schema) do
60
64
  seq = serial_sequence(name, primary_key(name))
@@ -65,6 +69,33 @@ module ChronoModel
65
69
  end
66
70
  end
67
71
 
72
+ # Rename indexes
73
+ #
74
+ pkey = primary_key(new_name)
75
+ _on_history_schema do
76
+ standard_index_names = %w(
77
+ inherit_pkey instance_history pkey
78
+ recorded_at timeline_consistency )
79
+
80
+ old_names = temporal_index_names(name, :validity) +
81
+ standard_index_names.map {|i| [name, i].join('_') }
82
+
83
+ new_names = temporal_index_names(new_name, :validity) +
84
+ standard_index_names.map {|i| [new_name, i].join('_') }
85
+
86
+ old_names.zip(new_names).each do |old, new|
87
+ execute "ALTER INDEX #{old} RENAME TO #{new}"
88
+ end
89
+ end
90
+
91
+ # Rename functions
92
+ #
93
+ %w( insert update delete ).each do |func|
94
+ execute "ALTER FUNCTION chronomodel_#{name}_#{func}() RENAME TO chronomodel_#{new_name}_#{func}"
95
+ end
96
+
97
+ # Rename the public view
98
+ #
68
99
  execute "ALTER VIEW #{name} RENAME TO #{new_name}"
69
100
 
70
101
  TableCache.del! name
@@ -96,7 +127,8 @@ module ChronoModel
96
127
 
97
128
  execute "ALTER TABLE #{table_name} SET SCHEMA #{TEMPORAL_SCHEMA}"
98
129
  _on_history_schema { chrono_create_history_for(table_name) }
99
- chrono_create_view_for(table_name)
130
+ chrono_create_view_for(table_name, options)
131
+ copy_indexes_to_history_for(table_name)
100
132
 
101
133
  TableCache.add! table_name
102
134
 
@@ -110,10 +142,9 @@ module ChronoModel
110
142
  execute %[
111
143
  INSERT INTO #{HISTORY_SCHEMA}.#{table_name}
112
144
  SELECT *,
113
- nextval('#{seq}') AS hid,
114
- timestamp '#{from}' AS valid_from,
115
- timestamp '9999-12-31 00:00:00' AS valid_to,
116
- timezone('UTC', now()) AS recorded_at
145
+ nextval('#{seq}') AS hid,
146
+ tsrange('#{from}', NULL) AS validity,
147
+ timezone('UTC', now()) AS recorded_at
117
148
  FROM #{TEMPORAL_SCHEMA}.#{table_name}
118
149
  ]
119
150
  end
@@ -125,7 +156,13 @@ module ChronoModel
125
156
  # Remove temporal features from this table
126
157
  #
127
158
  execute "DROP VIEW #{table_name}"
128
- _on_temporal_schema { execute "DROP FUNCTION IF EXISTS #{table_name}_ins() CASCADE" }
159
+
160
+ _on_temporal_schema do
161
+ %w( insert update delete ).each do |func|
162
+ execute "DROP FUNCTION IF EXISTS #{table_name}_#{func}() CASCADE"
163
+ end
164
+ end
165
+
129
166
  _on_history_schema { execute "DROP TABLE #{table_name}" }
130
167
 
131
168
  default_schema = select_value 'SELECT current_schema()'
@@ -146,7 +183,7 @@ module ChronoModel
146
183
  end
147
184
 
148
185
  # If dropping a temporal table, drops it from the temporal schema
149
- # adding the CASCADE option so to delete the history, view and rules.
186
+ # adding the CASCADE option so to delete the history, view and triggers.
150
187
  #
151
188
  def drop_table(table_name, *)
152
189
  return super unless is_chrono?(table_name)
@@ -186,7 +223,7 @@ module ChronoModel
186
223
  end
187
224
 
188
225
  # If adding a column to a temporal table, creates it in the table in
189
- # the temporal schema and updates the view rules.
226
+ # the temporal schema and updates the triggers.
190
227
  #
191
228
  def add_column(table_name, *)
192
229
  return super unless is_chrono?(table_name)
@@ -195,13 +232,13 @@ module ChronoModel
195
232
  # Add the column to the temporal table
196
233
  _on_temporal_schema { super }
197
234
 
198
- # Update the rules
235
+ # Update the triggers
199
236
  chrono_create_view_for(table_name)
200
237
  end
201
238
  end
202
239
 
203
240
  # If renaming a column of a temporal table, rename it in the table in
204
- # the temporal schema and update the view rules.
241
+ # the temporal schema and update the triggers.
205
242
  #
206
243
  def rename_column(table_name, *)
207
244
  return super unless is_chrono?(table_name)
@@ -211,14 +248,14 @@ module ChronoModel
211
248
  _on_temporal_schema { super }
212
249
  super
213
250
 
214
- # Update the rules
251
+ # Update the triggers
215
252
  chrono_create_view_for(table_name)
216
253
  end
217
254
  end
218
255
 
219
256
  # If removing a column from a temporal table, we are forced to drop the
220
257
  # view, then change the column from the table in the temporal schema and
221
- # eventually recreate the rules.
258
+ # eventually recreate the triggers.
222
259
  #
223
260
  def change_column(table_name, *)
224
261
  return super unless is_chrono?(table_name)
@@ -241,7 +278,7 @@ module ChronoModel
241
278
 
242
279
  # If removing a column from a temporal table, we are forced to drop the
243
280
  # view, then drop the column from the table in the temporal schema and
244
- # eventually recreate the rules.
281
+ # eventually recreate the triggers.
245
282
  #
246
283
  def remove_column(table_name, *)
247
284
  return super unless is_chrono?(table_name)
@@ -264,9 +301,7 @@ module ChronoModel
264
301
  end
265
302
  end
266
303
 
267
- # Create spatial indexes for timestamp search. Conceptually identical
268
- # to the EXCLUDE constraint in chrono_create_history_for, but without
269
- # the millisecond skew.
304
+ # Create spatial indexes for timestamp search.
270
305
  #
271
306
  # This index is used by +TimeMachine.at+, `.current` and `.past` to
272
307
  # build the temporal WHERE clauses that fetch the state of records at
@@ -275,115 +310,70 @@ module ChronoModel
275
310
  # Parameters:
276
311
  #
277
312
  # `table`: the table where to create indexes on
278
- # `from` : the starting timestamp field
279
- # `to` : the ending timestamp field
313
+ # `range`: the tsrange field
280
314
  #
281
315
  # Options:
282
316
  #
283
317
  # `:name`: the index name prefix, defaults to
284
- # {table_name}_temporal_{snapshot / from_col / to_col}
318
+ # index_{table}_temporal_on_{range / lower_range / upper_range}
285
319
  #
286
- def add_temporal_indexes(table, from, to, options = {})
287
- snapshot_idx, from_idx, to_idx =
288
- temporal_index_names(table, from, to, options)
320
+ def add_temporal_indexes(table, range, options = {})
321
+ range_idx, lower_idx, upper_idx =
322
+ temporal_index_names(table, range, options)
289
323
 
290
324
  chrono_alter_index(table, options) do
291
325
  execute <<-SQL
292
- CREATE INDEX #{snapshot_idx} ON #{table} USING gist (
293
- box(
294
- point( date_part( 'epoch', #{from} ), 0 ),
295
- point( date_part( 'epoch', #{to } ), 0 )
296
- )
297
- )
326
+ CREATE INDEX #{range_idx} ON #{table} USING gist ( #{range} )
298
327
  SQL
299
328
 
300
329
  # Indexes used for precise history filtering, sorting and, in history
301
- # tables, by UPDATE / DELETE rules
330
+ # tables, by UPDATE / DELETE triggers.
302
331
  #
303
- execute "CREATE INDEX #{from_idx} ON #{table} ( #{from} )"
304
- execute "CREATE INDEX #{to_idx } ON #{table} ( #{to } )"
332
+ execute "CREATE INDEX #{lower_idx} ON #{table} ( lower(#{range}) )"
333
+ execute "CREATE INDEX #{upper_idx} ON #{table} ( upper(#{range}) )"
305
334
  end
306
335
  end
307
336
 
308
- def remove_temporal_indexes(table, from, to, options = {})
309
- indexes = temporal_index_names(table, from, to, options)
337
+ def remove_temporal_indexes(table, range, options = {})
338
+ indexes = temporal_index_names(table, range, options)
310
339
 
311
340
  chrono_alter_index(table, options) do
312
341
  indexes.each {|idx| execute "DROP INDEX #{idx}" }
313
342
  end
314
343
  end
315
344
 
316
- def temporal_index_names(table, from, to, options)
317
- prefix = options[:name].presence || "#{table}_temporal"
318
-
319
- ["#{from}_and_#{to}", from, to].map do |suffix|
320
- [prefix, 'on', suffix].join('_')
321
- end
322
- end
323
-
324
-
325
- # Adds a CHECK constraint to the given +table+, to assure that
326
- # the value contained in the +from+ field is, by default, less
327
- # than the value contained in the +to+ field.
328
- #
329
- # The default `<` operator can be changed using the `:op` option.
330
- #
331
- def add_from_before_to_constraint(table, from, to, options = {})
332
- operator = options[:op].presence || '<'
333
- name = from_before_to_constraint_name(table, from, to)
334
-
335
- chrono_alter_constraint(table, options) do
336
- execute <<-SQL
337
- ALTER TABLE #{table} ADD CONSTRAINT
338
- #{name} CHECK (#{from} #{operator} #{to})
339
- SQL
340
- end
341
- end
345
+ def temporal_index_names(table, range, options = {})
346
+ prefix = options[:name].presence || "index_#{table}_temporal"
342
347
 
343
- def remove_from_before_to_constraint(table, from, to, options = {})
344
- name = from_before_to_constraint_name(table, from, to)
348
+ # When creating computed indexes (e.g. ends_on::timestamp + time
349
+ # '23:59:59'), remove everything following the field name.
350
+ range = range.to_s.sub(/\W.*/, '')
345
351
 
346
- chrono_alter_constraint(table, options) do
347
- execute <<-SQL
348
- ALTER TABLE #{table} DROP CONSTRAINT #{name}
349
- SQL
352
+ [range, "lower_#{range}", "upper_#{range}"].map do |suffix|
353
+ [prefix, 'on', suffix].join('_')
350
354
  end
351
355
  end
352
356
 
353
- def from_before_to_constraint_name(table, from, to)
354
- "#{table}_#{from}_before_#{to}"
355
- end
356
-
357
357
 
358
358
  # Adds an EXCLUDE constraint to the given table, to assure that
359
359
  # no more than one record can occupy a definite segment on a
360
360
  # timeline.
361
361
  #
362
- # The exclusion is implemented using a spatial gist index, that
363
- # uses boxes under the hood. Different records are identified by
364
- # default using the table's primary key - or you can specify your
365
- # field (or composite field) using the `:id` option.
366
- #
367
- def add_timeline_consistency_constraint(table, from, to, options = {})
362
+ def add_timeline_consistency_constraint(table, range, options = {})
368
363
  name = timeline_consistency_constraint_name(table)
369
- id = options[:id] || primary_key(table)
364
+ id = options[:id] || primary_key(table)
370
365
 
371
366
  chrono_alter_constraint(table, options) do
372
367
  execute <<-SQL
373
- ALTER TABLE #{table} ADD CONSTRAINT
374
- #{name} EXCLUDE USING gist (
375
- box(
376
- point( date_part( 'epoch', #{from} ), #{id} ),
377
- point( date_part( 'epoch', #{to } - INTERVAL '1 msec' ), #{id} )
378
- )
379
- WITH &&
380
- )
368
+ ALTER TABLE #{table} ADD CONSTRAINT #{name}
369
+ EXCLUDE USING gist ( #{id} WITH =, #{range} WITH && )
381
370
  SQL
382
371
  end
383
372
  end
384
373
 
385
- def remove_timeline_consistency_constraint(table, from, to, options = {})
386
- name = timeline_consistency_constraint_name(table)
374
+ def remove_timeline_consistency_constraint(table, options = {})
375
+ name = timeline_consistency_constraint_name(options[:prefix] || table)
376
+
387
377
  chrono_alter_constraint(table, options) do
388
378
  execute <<-SQL
389
379
  ALTER TABLE #{table} DROP CONSTRAINT #{name}
@@ -431,7 +421,7 @@ module ChronoModel
431
421
  @_on_schema_nesting -= 1
432
422
  end
433
423
 
434
- TableCache = (Class.new(HashWithIndifferentAccess) do
424
+ TableCache = (Class.new(Hash) do
435
425
  def all ; keys; ; end
436
426
  def add! table ; self[table.to_s] = true ; end
437
427
  def del! table ; self[table.to_s] = nil ; end
@@ -445,26 +435,155 @@ module ChronoModel
445
435
  _on_temporal_schema { table_exists?(table) } &&
446
436
  _on_history_schema { table_exists?(table) }
447
437
  end
438
+
439
+ rescue ActiveRecord::StatementInvalid => e
440
+ # means that we could not change the search path to check for
441
+ # table existence
442
+ if is_exception_class?(e, PG::InvalidSchemaName, PG::InvalidParameterValue)
443
+ return false
444
+ else
445
+ raise e
446
+ end
448
447
  end
449
448
 
450
- def chrono_create_schemas!
451
- [TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
452
- execute "CREATE SCHEMA #{schema}" unless schema_exists?(schema)
449
+ def is_exception_class?(e, *klasses)
450
+ if e.respond_to?(:original_exception)
451
+ klasses.any? { |k| e.is_a?(k) }
452
+ else
453
+ klasses.any? { |k| e.message =~ /#{k.name}/ }
453
454
  end
454
455
  end
455
456
 
456
- # Disable savepoints support, as they break history keeping.
457
- # http://archives.postgresql.org/pgsql-hackers/2012-08/msg01094.php
457
+ # HACK: Redefine tsrange parsing support, as it is broken currently.
458
+ #
459
+ # This self-made API is here because currently AR4 does not support
460
+ # open-ended ranges. The reasons are poor support in Ruby:
461
+ #
462
+ # https://bugs.ruby-lang.org/issues/6864
463
+ #
464
+ # and an instable interface in Active Record:
465
+ #
466
+ # https://github.com/rails/rails/issues/13793
467
+ # https://github.com/rails/rails/issues/14010
458
468
  #
459
- def supports_savepoints?
460
- false
469
+ # so, for now, we are implementing our own.
470
+ #
471
+ class TSRange < OID::Type
472
+ def extract_bounds(value)
473
+ from, to = value[1..-2].split(',')
474
+ {
475
+ from: (value[1] == ',' || from == '-infinity') ? nil : from[1..-2],
476
+ to: (value[-2] == ',' || to == 'infinity') ? nil : to[1..-2],
477
+ #exclude_start: (value[0] == '('),
478
+ #exclude_end: (value[-1] == ')')
479
+ }
480
+ end
481
+
482
+ def type_cast(value)
483
+ extracted = extract_bounds(value)
484
+
485
+ from = Conversions.string_to_utc_time extracted[:from]
486
+ to = Conversions.string_to_utc_time extracted[:to ]
487
+
488
+ [from, to]
489
+ end
461
490
  end
462
491
 
463
- def create_savepoint; end
464
- def rollback_to_savepoint; end
465
- def release_savepoint; end
492
+ def chrono_setup!
493
+ chrono_create_schemas
494
+ chrono_setup_type_map
495
+
496
+ chrono_upgrade_structure!
497
+ end
498
+
499
+ # Copy the indexes from the temporal table to the history table if the indexes
500
+ # are not already created with the same name.
501
+ #
502
+ # Uniqueness is voluntarily ignored, as it doesn't make sense on history
503
+ # tables.
504
+ #
505
+ # Ref: GitHub pull #21.
506
+ #
507
+ def copy_indexes_to_history_for(table_name)
508
+ history_indexes = _on_history_schema { indexes(table_name) }.map(&:name)
509
+ temporal_indexes = _on_temporal_schema { indexes(table_name) }
510
+
511
+ temporal_indexes.each do |index|
512
+ next if history_indexes.include?(index.name)
513
+
514
+ _on_history_schema do
515
+ execute %[
516
+ CREATE INDEX #{index.name} ON #{table_name}
517
+ USING #{index.using} ( #{index.columns.join(', ')} )
518
+ ], 'Copy index from temporal to history'
519
+ end
520
+ end
521
+ end
466
522
 
467
523
  private
524
+ # Create the temporal and history schemas, unless they already exist
525
+ #
526
+ def chrono_create_schemas
527
+ [TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
528
+ execute "CREATE SCHEMA #{schema}" unless schema_exists?(schema)
529
+ end
530
+ end
531
+
532
+ # Adds the above TSRange class to the PG Adapter OID::TYPE_MAP
533
+ #
534
+ def chrono_setup_type_map
535
+ OID::TYPE_MAP[3908] = TSRange.new
536
+ end
537
+
538
+ # Upgrades existing structure for each table, if required.
539
+ # TODO: allow upgrades from pre-0.6 structure with box() and stuff.
540
+ #
541
+ def chrono_upgrade_structure!
542
+ transaction do
543
+ current = VERSION
544
+
545
+ _on_temporal_schema { tables }.each do |table_name|
546
+ next unless is_chrono?(table_name)
547
+ metadata = chrono_metadata_for(table_name)
548
+ version = metadata['chronomodel']
549
+
550
+ if version.blank? # FIXME
551
+ raise Error, "ChronoModel found structures created by a too old version. Cannot upgrade right now."
552
+ end
553
+
554
+ next if version == current
555
+
556
+ logger.info "ChronoModel: upgrading #{table_name} from #{version} to #{current}"
557
+ chrono_create_view_for(table_name)
558
+ logger.info "ChronoModel: upgrade complete"
559
+ end
560
+ end
561
+ end
562
+
563
+ def chrono_metadata_for(table)
564
+ comment = select_value(
565
+ "SELECT obj_description(#{quote(table)}::regclass)",
566
+ "ChronoModel metadata for #{table}") if table_exists?(table)
567
+
568
+ MultiJson.load(comment || '{}').with_indifferent_access
569
+ end
570
+
571
+ def chrono_metadata_set(table, metadata)
572
+ comment = MultiJson.dump(metadata)
573
+
574
+ execute %[
575
+ COMMENT ON VIEW #{table} IS #{quote(comment)}
576
+ ]
577
+ end
578
+
579
+ def add_history_validity_constraint(table, pkey)
580
+ add_timeline_consistency_constraint(table, :validity, :id => pkey, :on_current_schema => true)
581
+ end
582
+
583
+ def remove_history_validity_constraint(table, options = {})
584
+ remove_timeline_consistency_constraint(table, options.merge(:on_current_schema => true))
585
+ end
586
+
468
587
  # Create the history table in the history schema
469
588
  def chrono_create_history_for(table)
470
589
  parent = "#{TEMPORAL_SCHEMA}.#{table}"
@@ -473,20 +592,12 @@ module ChronoModel
473
592
  execute <<-SQL
474
593
  CREATE TABLE #{table} (
475
594
  hid SERIAL PRIMARY KEY,
476
- valid_from timestamp NOT NULL,
477
- valid_to timestamp NOT NULL DEFAULT '9999-12-31',
595
+ validity #{RANGE_TYPE} NOT NULL,
478
596
  recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now())
479
597
  ) INHERITS ( #{parent} )
480
598
  SQL
481
599
 
482
- add_from_before_to_constraint(table, :valid_from, :valid_to,
483
- :on_current_schema => true)
484
-
485
- add_timeline_consistency_constraint(table, :valid_from, :valid_to,
486
- :id => p_pkey, :on_current_schema => true)
487
-
488
- # Inherited primary key
489
- execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
600
+ add_history_validity_constraint(table, p_pkey)
490
601
 
491
602
  chrono_create_history_indexes_for(table, p_pkey)
492
603
  end
@@ -496,130 +607,142 @@ module ChronoModel
496
607
  # TODO remove me.
497
608
  p_pkey ||= primary_key("#{TEMPORAL_SCHEMA}.#{table}")
498
609
 
499
- add_temporal_indexes table, :valid_from, :valid_to,
500
- :on_current_schema => true
610
+ add_temporal_indexes table, :validity, :on_current_schema => true
501
611
 
612
+ execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
502
613
  execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
503
614
  execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
504
615
  end
505
616
 
506
- # Create the public view and its rewrite rules
617
+ # Create the public view and its INSTEAD OF triggers
507
618
  #
508
- def chrono_create_view_for(table)
619
+ def chrono_create_view_for(table, options = nil)
509
620
  pk = primary_key(table)
510
621
  current = [TEMPORAL_SCHEMA, table].join('.')
511
622
  history = [HISTORY_SCHEMA, table].join('.')
623
+ seq = serial_sequence(current, pk)
624
+
625
+ options ||= chrono_metadata_for(table)
512
626
 
513
627
  # SELECT - return only current data
514
628
  #
515
629
  execute "DROP VIEW #{table}" if table_exists? table
516
- execute "CREATE VIEW #{table} AS SELECT *, xmin AS __xid FROM ONLY #{current}"
630
+ execute "CREATE VIEW #{table} AS SELECT * FROM ONLY #{current}"
631
+
632
+ # Set default values on the view (closes #12)
633
+ #
634
+ chrono_metadata_set(table, options.merge(:chronomodel => VERSION))
635
+
636
+ columns(table).each do |column|
637
+ default = column.default.nil? ? column.default_function : quote(column.default, column)
638
+ next if column.name == pk || default.nil?
517
639
 
518
- columns = columns(table).map{|c| quote_column_name(c.name)}
640
+ execute "ALTER VIEW #{table} ALTER COLUMN #{column.name} SET DEFAULT #{default}"
641
+ end
642
+
643
+ columns = columns(table).map {|c| quote_column_name(c.name)}
519
644
  columns.delete(quote_column_name(pk))
520
645
 
521
- updates = columns.map {|c| "#{c} = new.#{c}"}.join(",\n")
646
+ fields, values = columns.join(', '), columns.map {|c| "NEW.#{c}"}.join(', ')
647
+
648
+ # Columns to be journaled. By default everything except updated_at (GH #7)
649
+ #
650
+ journal = if options[:journal]
651
+ options[:journal].map {|col| quote_column_name(col)}
652
+
653
+ elsif options[:no_journal]
654
+ columns - options[:no_journal].map {|col| quote_column_name(col)}
655
+
656
+ elsif options[:full_journal]
657
+ columns
658
+
659
+ else
660
+ columns - [ quote_column_name('updated_at') ]
661
+ end
522
662
 
523
- fields, values = columns.join(', '), columns.map {|c| "new.#{c}"}.join(', ')
524
- fields_with_pk, values_with_pk = "#{pk}, " << fields, "new.#{pk}, " << values
663
+ journal &= columns
525
664
 
526
665
  # INSERT - insert data both in the temporal table and in the history one.
527
666
  #
528
- # A trigger is required if there is a serial ID column, as rules by
529
- # design cannot handle the following case:
667
+ # The serial sequence is invoked manually only if the PK is NULL, to
668
+ # allow setting the PK to a specific value (think migration scenario).
530
669
  #
531
- # * INSERT INTO ... SELECT: if using currval(), all the rows
532
- # inserted in the history will have the same identity value;
533
- #
534
- # * if using a separate sequence to solve the above case, it may go
535
- # out of sync with the main one if an INSERT statement fails due
536
- # to a table constraint (the first one is nextval()'ed but the
537
- # nextval() on the history one never happens)
538
- #
539
- # So, only for this case, we resort to an AFTER INSERT FOR EACH ROW trigger.
540
- #
541
- # Ref: GH Issue #4.
542
- #
543
- if serial_sequence(current, pk).present?
544
- execute <<-SQL
545
- CREATE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
546
- INSERT INTO #{current} ( #{fields} ) VALUES ( #{values} )
547
- RETURNING #{pk}, #{fields}, xmin
548
- );
549
-
550
- CREATE OR REPLACE FUNCTION #{current}_ins() RETURNS TRIGGER AS $$
551
- BEGIN
552
- INSERT INTO #{history} ( #{fields_with_pk}, valid_from )
553
- VALUES ( #{values_with_pk}, timezone('UTC', now()) );
554
- RETURN NULL;
555
- END;
556
- $$ LANGUAGE plpgsql;
670
+ execute <<-SQL
671
+ CREATE OR REPLACE FUNCTION chronomodel_#{table}_insert() RETURNS TRIGGER AS $$
672
+ BEGIN
673
+ IF NEW.#{pk} IS NULL THEN
674
+ NEW.#{pk} := nextval('#{seq}');
675
+ END IF;
557
676
 
558
- DROP TRIGGER IF EXISTS history_ins ON #{current};
677
+ INSERT INTO #{current} ( #{pk}, #{fields} )
678
+ VALUES ( NEW.#{pk}, #{values} );
559
679
 
560
- CREATE TRIGGER history_ins AFTER INSERT ON #{current}
561
- FOR EACH ROW EXECUTE PROCEDURE #{current}_ins();
562
- SQL
563
- else
564
- execute <<-SQL
565
- CREATE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
680
+ INSERT INTO #{history} ( #{pk}, #{fields}, validity )
681
+ VALUES ( NEW.#{pk}, #{values}, tsrange(timezone('UTC', now()), NULL) );
566
682
 
567
- INSERT INTO #{current} ( #{fields_with_pk} ) VALUES ( #{values_with_pk} );
683
+ RETURN NEW;
684
+ END;
685
+ $$ LANGUAGE plpgsql;
568
686
 
569
- INSERT INTO #{history} ( #{fields_with_pk}, valid_from )
570
- VALUES ( #{values_with_pk}, timezone('UTC', now()) )
571
- RETURNING #{fields_with_pk}, xmin
572
- )
573
- SQL
574
- end
687
+ DROP TRIGGER IF EXISTS chronomodel_insert ON #{table};
688
+
689
+ CREATE TRIGGER chronomodel_insert INSTEAD OF INSERT ON #{table}
690
+ FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_insert();
691
+ SQL
575
692
 
576
693
  # UPDATE - set the last history entry validity to now, save the current data
577
694
  # in a new history entry and update the temporal table with the new data.
578
-
579
- # If this is the first statement of a transaction, inferred by the last
580
- # transaction ID that updated the row, create a new row in the history.
581
695
  #
582
- # The current transaction ID is returned by txid_current() as a 64-bit
583
- # signed integer, while the last transaction ID that changed a row is
584
- # stored into a 32-bit unsigned integer in the __xid column. As XIDs
585
- # wrap over time, txid_current() adds an "epoch" counter in the most
586
- # significant bits (http://bit.ly/W2Srt7) of the int - thus here we
587
- # remove it by and'ing with 2^32-1.
696
+ # If there are no changes, this trigger suppresses redundant updates.
588
697
  #
589
- # XID are 32-bit unsigned integers, and by design cannot be casted nor
590
- # compared to anything else, adding a CAST or an operator requires
591
- # super-user privileges, so here we do a double-cast from varchar to
592
- # int8, to finally compare it with the current XID. We're using 64bit
593
- # integers as in PG there is no 32-bit unsigned data type.
698
+ # If a row in the history with the current ID and current timestamp already
699
+ # exists, update it with new data. This logic makes possible to "squash"
700
+ # together changes made in a transaction in a single history row.
594
701
  #
595
702
  execute <<-SQL
596
- CREATE RULE #{table}_upd_first AS ON UPDATE TO #{table}
597
- WHERE old.__xid::char(10)::int8 <> (txid_current() & (2^32-1)::int8)
598
- DO INSTEAD (
703
+ CREATE OR REPLACE FUNCTION chronomodel_#{table}_update() RETURNS TRIGGER AS $$
704
+ DECLARE _now timestamp;
705
+ DECLARE _hid integer;
706
+ DECLARE _old record;
707
+ DECLARE _new record;
708
+ BEGIN
709
+ IF OLD IS NOT DISTINCT FROM NEW THEN
710
+ RETURN NULL;
711
+ END IF;
599
712
 
600
- UPDATE #{history} SET valid_to = timezone('UTC', now())
601
- WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
713
+ _old := row(#{journal.map {|c| "OLD.#{c}" }.join(', ')});
714
+ _new := row(#{journal.map {|c| "NEW.#{c}" }.join(', ')});
602
715
 
603
- INSERT INTO #{history} ( #{pk}, #{fields}, valid_from )
604
- VALUES ( old.#{pk}, #{values}, timezone('UTC', now()) );
716
+ IF _old IS NOT DISTINCT FROM _new THEN
717
+ UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
718
+ RETURN NEW;
719
+ END IF;
605
720
 
606
- UPDATE ONLY #{current} SET #{updates}
607
- WHERE #{pk} = old.#{pk}
608
- )
609
- SQL
721
+ _now := timezone('UTC', now());
722
+ _hid := NULL;
610
723
 
611
- # Else, update the already present history row with new data. This logic
612
- # makes possible to "squash" together changes made in a transaction in a
613
- # single history row, assuring timestamps consistency.
614
- #
615
- execute <<-SQL
616
- CREATE RULE #{table}_upd_next AS ON UPDATE TO #{table} DO INSTEAD (
617
- UPDATE #{history} SET #{updates}
618
- WHERE #{pk} = old.#{pk} AND valid_from = timezone('UTC', now());
724
+ SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;
725
+
726
+ IF _hid IS NOT NULL THEN
727
+ UPDATE #{history} SET ( #{fields} ) = ( #{values} ) WHERE hid = _hid;
728
+ ELSE
729
+ UPDATE #{history} SET validity = tsrange(lower(validity), _now)
730
+ WHERE #{pk} = OLD.#{pk} AND upper_inf(validity);
619
731
 
620
- UPDATE ONLY #{current} SET #{updates}
621
- WHERE #{pk} = old.#{pk}
622
- )
732
+ INSERT INTO #{history} ( #{pk}, #{fields}, validity )
733
+ VALUES ( OLD.#{pk}, #{values}, tsrange(_now, NULL) );
734
+ END IF;
735
+
736
+ UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
737
+
738
+ RETURN NEW;
739
+ END;
740
+ $$ LANGUAGE plpgsql;
741
+
742
+ DROP TRIGGER IF EXISTS chronomodel_update ON #{table};
743
+
744
+ CREATE TRIGGER chronomodel_update INSTEAD OF UPDATE ON #{table}
745
+ FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_update();
623
746
  SQL
624
747
 
625
748
  # DELETE - save the current data in the history and eventually delete the
@@ -628,19 +751,28 @@ module ChronoModel
628
751
  # DELETEd in the same transaction.
629
752
  #
630
753
  execute <<-SQL
631
- CREATE RULE #{table}_del AS ON DELETE TO #{table} DO INSTEAD (
754
+ CREATE OR REPLACE FUNCTION chronomodel_#{table}_delete() RETURNS TRIGGER AS $$
755
+ DECLARE _now timestamp;
756
+ BEGIN
757
+ _now := timezone('UTC', now());
632
758
 
633
- DELETE FROM #{history}
634
- WHERE #{pk} = old.#{pk}
635
- AND valid_from = timezone('UTC', now())
636
- AND valid_to = '9999-12-31';
759
+ DELETE FROM #{history}
760
+ WHERE #{pk} = old.#{pk} AND validity = tsrange(_now, NULL);
637
761
 
638
- UPDATE #{history} SET valid_to = timezone('UTC', now())
639
- WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
762
+ UPDATE #{history} SET validity = tsrange(lower(validity), _now)
763
+ WHERE #{pk} = old.#{pk} AND upper_inf(validity);
640
764
 
641
- DELETE FROM ONLY #{current}
642
- WHERE #{current}.#{pk} = old.#{pk}
643
- )
765
+ DELETE FROM ONLY #{current}
766
+ WHERE #{pk} = old.#{pk};
767
+
768
+ RETURN OLD;
769
+ END;
770
+ $$ LANGUAGE plpgsql;
771
+
772
+ DROP TRIGGER IF EXISTS chronomodel_delete ON #{table};
773
+
774
+ CREATE TRIGGER chronomodel_delete INSTEAD OF DELETE ON #{table}
775
+ FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_delete();
644
776
  SQL
645
777
  end
646
778
 
@@ -650,12 +782,14 @@ module ChronoModel
650
782
  #
651
783
  def chrono_alter(table_name)
652
784
  transaction do
785
+ options = chrono_metadata_for(table_name)
786
+
653
787
  execute "DROP VIEW #{table_name}"
654
788
 
655
789
  _on_temporal_schema { yield }
656
790
 
657
- # Recreate the rules
658
- chrono_create_view_for(table_name)
791
+ # Recreate the triggers
792
+ chrono_create_view_for(table_name, options)
659
793
  end
660
794
  end
661
795