chrono_model 0.5.3 → 0.8.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.
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