chrono_model 0.4.0 → 0.5.0.beta

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- chrono_model (0.4.0)
4
+ chrono_model (0.5.0.beta)
5
5
  activerecord (~> 3.2)
6
6
  pg
7
7
 
data/README.md CHANGED
@@ -13,9 +13,11 @@ having to deal with it.
13
13
 
14
14
  The application model is backed by an updatable view that behaves exactly like a plain table, while
15
15
  behind the scenes the database redirects the queries to concrete tables using
16
- [the rule system](http://www.postgresql.org/docs/9.0/static/rules-update.html).
16
+ [the rule system](http://www.postgresql.org/docs/9.0/static/rules-update.html) for most of the cases,
17
+ and using an `AFTER FOR EACH ROW` trigger only for the `INSERT` case on tables
18
+ having a `SERIAL` primary key.
17
19
 
18
- Current data is hold in a table in the `current` [schema](http://www.postgresql.org/docs/9.0/static/ddl-schemas.html),
20
+ Current data is hold in a table in the `temporal` [schema](http://www.postgresql.org/docs/9.0/static/ddl-schemas.html),
19
21
  while history in hold in another table in the `history` schema. The latter
20
22
  [inherits](http://www.postgresql.org/docs/9.0/static/ddl-inherit.html) from the former, to get
21
23
  automated schema updates for free. Partitioning of history is even possible but not implemented
@@ -25,20 +27,19 @@ The updatable view is created in the default `public` schema, making it visible
25
27
 
26
28
  All Active Record schema migration statements are decorated with code that handles the temporal
27
29
  structure by e.g. keeping the view rules in sync or dropping/recreating it when required by your
28
- migrations. A schema dumper is available as well.
30
+ migrations.
29
31
 
30
32
  Data extraction at a single point in time and even `JOIN`s between temporal and non-temporal data
31
- is implemented using
32
- [Common Table Expressions](http://www.postgresql.org/docs/9.0/static/queries-with.html)
33
- (WITH queries) and a `WHERE date >= valid_from AND date < valid_to` clause, generated automatically
34
- by the provided `TimeMachine` module to be included in your models.
33
+ is implemented using sub-selects and a `WHERE` generated by the provided `TimeMachine` module to
34
+ be included in your models.
35
35
 
36
- Optimal temporal timestamps indexing is provided for both PostgreSQL 9.0 and 9.1 query planners.
36
+ The `WHERE` is optimized using a spatial GiST index in which time is represented represented by
37
+ boxes and the filtering is done using the overlapping (`&&`) geometry operator.
37
38
 
38
39
  All timestamps are (forcibly) stored in the UTC time zone, bypassing the `AR::Base.config.default_timezone`
39
40
  setting.
40
41
 
41
- See [README.sql](https://github.com/ifad/chronomodel/blob/master/README.sql) for the plain SQL
42
+ See [README.sql](https://github.com/ifad/chronomodel/blob/master/README.sql) for the plain SQL
42
43
  defining this temporal schema for a single table.
43
44
 
44
45
 
@@ -121,9 +122,17 @@ will make an `as_of` class method available to your model. E.g.:
121
122
 
122
123
  Will execute:
123
124
 
124
- WITH countries AS (
125
- SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
126
- ) SELECT * FROM countries
125
+ SELECT "countries".* FROM (
126
+ SELECT "history"."countries".* FROM "history"."countries"
127
+ WHERE box(
128
+ point( date_part( 'epoch', '#{1.year.ago.utc}'::timestamp ), 0 ),
129
+ point( date_part( 'epoch', '#{1.year.ago.utc}'::timestamp ), 0 )
130
+ ) &&
131
+ box(
132
+ point( date_part( 'epoch', "history"."addresses"."valid_from" ), 0 ),
133
+ point( date_part( 'epoch', "history"."addresses"."valid_to" ), 0 ),
134
+ )
135
+ ) AS "countries"
127
136
 
128
137
  This work on associations using temporal extensions as well:
129
138
 
@@ -131,25 +140,32 @@ This work on associations using temporal extensions as well:
131
140
 
132
141
  Will execute:
133
142
 
134
- WITH countries AS (
135
- SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
136
- ) SELECT * FROM countries LIMIT 1
143
+ # ... countries history query ...
144
+ LIMIT 1
145
+
146
+ SELECT * FROM (
147
+ SELECT "history"."compositions".* FROM "history"."compositions"
148
+ WHERE box(
149
+ point( date_part( 'epoch', '#{above_timestamp}'::timestamp ), 0 ),
150
+ point( date_part( 'epoch', '#{above_timestamp}'::timestamp ), 0 )
151
+ ) &&
152
+ box(
153
+ point( date_part( 'epoch', "history"."addresses"."valid_from" ), 0 ),
154
+ point( date_part( 'epoch', "history"."addresses"."valid_to" ), 0 ),
155
+ )
156
+ ) AS "compositions" WHERE country_id = X
137
157
 
138
- WITH compositions AS (
139
- SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
140
- ) SELECT * FROM compositions WHERE country_id = X
141
-
142
158
  And `.joins` works as well:
143
159
 
144
160
  Country.as_of(1.month.ago).joins(:compositions)
145
-
161
+
146
162
  Will execute:
147
163
 
148
- WITH countries AS (
149
- SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
150
- ), compositions AS (
151
- SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
152
- ) SELECT * FROM countries INNER JOIN countries ON compositions.country_id = countries.id
164
+ SELECT "countries".* FROM (
165
+ # .. countries history query ..
166
+ ) AS "countries" INNER JOIN (
167
+ # .. compositions history query ..
168
+ ) AS "compositions" ON compositions.country_id = countries.id
153
169
 
154
170
  More methods are provided, see the
155
171
  [TimeMachine](https://github.com/ifad/chronomodel/blob/master/lib/chrono_model/time_machine.rb) source
@@ -166,25 +182,38 @@ them in your output, use `rake VERBOSE=true`.
166
182
 
167
183
  ## Caveats
168
184
 
169
- * `.includes` still doesn't work, but it'll fixed soon.
185
+ * The rules and temporal indexes cannot be saved in schema.rb. The AR
186
+ schema dumper is quite basic, and it isn't (currently) extensible.
187
+ As we're using many database-specific features, you'll better off with
188
+ the SQL schema dumper (`config.active_record.schema_format = :sql` in
189
+ `config/application.rb`). Be sure to add [these
190
+ files](https://gist.github.com/4548844) in your `lib/tasks` if you want
191
+ `rake db:setup` to work.
192
+
193
+ * `.includes` still doesn't work, but it'll fixed.
170
194
 
171
- * Some monkeypatching has been necessary both to `ActiveRecord::Relation` and
172
- to `Arel::Visitors::ToSql` to fix a bug with `WITH` queries generation. This
173
- will be reported to the upstream with a pull request after extensive testing.
195
+ * The history queries are very verbose, they should be factored out using a
196
+ `FUNCTION`.
174
197
 
175
198
  * The migration statements extension is implemented using a Man-in-the-middle
176
199
  class that inherits from the PostgreSQL adapter, and that relies on some
177
- private APIs. This should be made more maintainable, maybe by implementing
178
- an extension framework for connection adapters. This library will (clearly)
179
- never be merged into Rails, as it is against its principle of treating the
180
- SQL database as a dummy data store.
181
-
182
- * The schema dumper is WAY TOO hacky.
200
+ private APIs. This should be made more maintainable, maybe by requiring
201
+ the use of `adapter: chronomodel` or `adapter: chrono_postgresql`.
183
202
 
184
203
  * Savepoints are disabled, because there is
185
204
  [currently](http://archives.postgresql.org/pgsql-hackers/2012-08/msg01094.php)
186
205
  no way to identify a subtransaction belonging to the current transaction.
187
206
 
207
+ * The choice of using subqueries instead of [Common Table Expressions](http://www.postgresql.org/docs/9.0/static/queries-with.html)
208
+ was dictated by the fact that CTEs [currently acts as an optimization
209
+ fence](http://archives.postgresql.org/pgsql-hackers/2012-09/msg00700.php).
210
+ If it will be possible [to opt-out of the
211
+ fence](http://archives.postgresql.org/pgsql-hackers/2012-10/msg00024.php)
212
+ in the future, they will be probably be used again as they were [in the
213
+ past](https://github.com/ifad/chronomodel/commit/18f4c4b), because the
214
+ resulting queries are much more readable, and do not inhibit using
215
+ `.from()` from ARel.
216
+
188
217
 
189
218
  ## Contributing
190
219
 
data/README.sql CHANGED
@@ -24,32 +24,36 @@ create table history.countries (
24
24
  hid serial primary key,
25
25
  valid_from timestamp not null,
26
26
  valid_to timestamp not null default '9999-12-31',
27
- recorded_at timestamp not null default now(),
27
+ recorded_at timestamp not null default timezone('UTC', now()),
28
28
 
29
29
  constraint from_before_to check (valid_from < valid_to),
30
30
 
31
31
  constraint overlapping_times exclude using gist (
32
32
  box(
33
- point( extract( epoch from valid_from), id ),
34
- point( extract( epoch from valid_to - interval '1 millisecond'), id )
33
+ point( date_part( 'epoch', valid_from), id ),
34
+ point( date_part( 'epoch', valid_to - interval '1 millisecond'), id )
35
35
  ) with &&
36
36
  )
37
37
  ) inherits ( temporal.countries );
38
38
 
39
39
  -- Inherited primary key
40
- create index country_inherit_pkey ON countries ( id )
40
+ create index country_inherit_pkey on history.countries ( id )
41
41
 
42
- -- Snapshot of all entities at a specific point in time
43
- create index country_snapshot on history.countries ( valid_from, valid_to )
44
-
45
- -- Snapshot of a single entity at a specific point in time
46
- create index country_instance_snapshot on history.countries ( id, valid_from, valid_to )
42
+ -- 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
+ )
47
49
 
48
- -- History update
49
- create index country_instance_update on history.countries ( id, valid_to )
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 )
50
54
 
51
55
  -- Single instance whole history
52
- create index country_instance_history on history.countries ( id, recorded_at )
56
+ create index country_instance_history on history.countries ( id, recorded_at )
53
57
 
54
58
 
55
59
  -- The countries view, what the Rails' application ORM will actually CRUD on, and
@@ -57,42 +61,84 @@ create index country_instance_history on history.countries ( id, recorded_at )
57
61
  --
58
62
  -- SELECT - return only current data
59
63
  --
60
- create view public.countries as select * from only temporal.countries;
64
+ create view public.countries as select *, xmin as __xid from only temporal.countries;
61
65
 
62
66
  -- INSERT - insert data both in the current data table and in the history table.
63
- -- Return data from the history table as the RETURNING clause must be the last
64
- -- one in the rule.
67
+ --
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;
73
+ --
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)
78
+ --
79
+ -- So, only for this case, we resort to an AFTER INSERT FOR EACH ROW trigger.
80
+ --
81
+ -- Ref: GH Issue #4.
82
+ --
65
83
  create rule countries_ins as on insert to public.countries do instead (
66
- insert into temporal.countries ( name ) values ( new.name );
67
84
 
68
- insert into history.countries ( id, name, valid_from )
69
- values ( currval('temporal.countries_id_seq'), new.name, now() )
70
- returning ( new.name )
85
+ insert into temporal.countries ( name ) values ( new.name );
86
+ returning ( id, new.name, xmin )
71
87
  );
72
88
 
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;
94
+ end;
95
+ $$ language plpgsql;
96
+
97
+ create trigger history_ins after insert on temporal.countries_ins()
98
+ for each row execute procedure temporal.countries_ins();
99
+
73
100
  -- UPDATE - set the last history entry validity to now, save the current data in
74
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.
75
104
  --
76
- create rule countries_upd as on update to countries do instead (
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 (
77
108
  update history.countries
78
- set valid_to = now()
79
- where id = old.id and valid_to = '9999-12-31';
109
+ set valid_to = timezone('UTC', now())
110
+ where id = old.id and valid_to = '9999-12-31';
80
111
 
81
112
  insert into history.countries ( id, name, valid_from )
82
- values ( old.id, new.name, now() );
113
+ values ( old.id, new.name, timezone('UTC', now()) );
83
114
 
84
115
  update only temporal.countries
85
- set name = new.name
86
- where id = old.id
116
+ set name = new.name
117
+ where id = old.id
87
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
+ )
88
128
 
89
129
  -- DELETE - save the current data in the history and eventually delete the data
90
- -- from the current table.
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.
91
132
  --
92
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
+
93
139
  update history.countries
94
140
  set valid_to = now()
95
- where id = old.id and valid_to = '9999-12-31';
141
+ where id = old.id and valid_to = '9999-12-31';
96
142
 
97
143
  delete from only temporal.countries
98
144
  where temporal.countries.id = old.id
@@ -28,7 +28,10 @@ module ChronoModel
28
28
  return super unless options[:temporal]
29
29
 
30
30
  if options[:id] == false
31
- raise Error, "Temporal tables require a primary key."
31
+ logger.warn "WARNING - Temporal Temporal tables require a primary key."
32
+ logger.warn "WARNING - Creating a \"__chrono_id\" primary key to fulfill the requirement"
33
+
34
+ options[:id] = '__chrono_id'
32
35
  end
33
36
 
34
37
  # Create required schemas
@@ -87,6 +90,10 @@ module ChronoModel
87
90
  if !is_chrono?(table_name)
88
91
  # Add temporal features to this table
89
92
  #
93
+ if !primary_key(table_name)
94
+ execute "ALTER TABLE #{table_name} ADD __chrono_id SERIAL PRIMARY KEY"
95
+ end
96
+
90
97
  execute "ALTER TABLE #{table_name} SET SCHEMA #{TEMPORAL_SCHEMA}"
91
98
  _on_history_schema { chrono_create_history_for(table_name) }
92
99
  chrono_create_view_for(table_name)
@@ -118,10 +125,15 @@ module ChronoModel
118
125
  # Remove temporal features from this table
119
126
  #
120
127
  execute "DROP VIEW #{table_name}"
128
+ _on_temporal_schema { execute "DROP FUNCTION IF EXISTS #{table_name}_ins() CASCADE" }
121
129
  _on_history_schema { execute "DROP TABLE #{table_name}" }
122
130
 
123
131
  default_schema = select_value 'SELECT current_schema()'
124
132
  _on_temporal_schema do
133
+ if primary_key(table_name) == '__chrono_id'
134
+ execute "ALTER TABLE #{table_name} DROP __chrono_id"
135
+ end
136
+
125
137
  execute "ALTER TABLE #{table_name} SET SCHEMA #{default_schema}"
126
138
  end
127
139
 
@@ -252,6 +264,138 @@ module ChronoModel
252
264
  end
253
265
  end
254
266
 
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.
270
+ #
271
+ # This index is used by +TimeMachine.at+, `.current` and `.past` to
272
+ # build the temporal WHERE clauses that fetch the state of records at
273
+ # a single point in time.
274
+ #
275
+ # Parameters:
276
+ #
277
+ # `table`: the table where to create indexes on
278
+ # `from` : the starting timestamp field
279
+ # `to` : the ending timestamp field
280
+ #
281
+ # Options:
282
+ #
283
+ # `:name`: the index name prefix, defaults to
284
+ # {table_name}_temporal_{snapshot / from_col / to_col}
285
+ #
286
+ def add_temporal_indexes(table, from, to, options = {})
287
+ snapshot_idx, from_idx, to_idx =
288
+ temporal_index_names(table, from, to, options)
289
+
290
+ chrono_alter_index(table, options) do
291
+ 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
+ )
298
+ SQL
299
+
300
+ # Indexes used for precise history filtering, sorting and, in history
301
+ # tables, by UPDATE / DELETE rules
302
+ #
303
+ execute "CREATE INDEX #{from_idx} ON #{table} ( #{from} )"
304
+ execute "CREATE INDEX #{to_idx } ON #{table} ( #{to } )"
305
+ end
306
+ end
307
+
308
+ def remove_temporal_indexes(table, from, to, options = {})
309
+ indexes = temporal_index_names(table, from, to, options)
310
+
311
+ chrono_alter_index(table, options) do
312
+ indexes.each {|idx| execute "DROP INDEX #{idx}" }
313
+ end
314
+ end
315
+
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
342
+
343
+ def remove_from_before_to_constraint(table, from, to, options = {})
344
+ name = from_before_to_constraint_name(table, from, to)
345
+
346
+ chrono_alter_constraint(table, options) do
347
+ execute <<-SQL
348
+ ALTER TABLE #{table} DROP CONSTRAINT #{name}
349
+ SQL
350
+ end
351
+ end
352
+
353
+ def from_before_to_constraint_name(table, from, to)
354
+ "#{table}_#{from}_before_#{to}"
355
+ end
356
+
357
+
358
+ # Adds an EXCLUDE constraint to the given table, to assure that
359
+ # no more than one record can occupy a definite segment on a
360
+ # timeline.
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 = {})
368
+ name = timeline_consistency_constraint_name(table)
369
+ id = options[:id] || primary_key(table)
370
+
371
+ chrono_alter_constraint(table, options) do
372
+ 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
+ )
381
+ SQL
382
+ end
383
+ end
384
+
385
+ def remove_timeline_consistency_constraint(table, from, to, options = {})
386
+ name = timeline_consistency_constraint_name(table)
387
+ chrono_alter_constraint(table, options) do
388
+ execute <<-SQL
389
+ ALTER TABLE #{table} DROP CONSTRAINT #{name}
390
+ SQL
391
+ end
392
+ end
393
+
394
+ def timeline_consistency_constraint_name(table)
395
+ "#{table}_timeline_consistency"
396
+ end
397
+
398
+
255
399
  # Evaluates the given block in the given +schema+ search path.
256
400
  #
257
401
  # By default, nested call are allowed, to disable this feature
@@ -331,39 +475,32 @@ module ChronoModel
331
475
  hid SERIAL PRIMARY KEY,
332
476
  valid_from timestamp NOT NULL,
333
477
  valid_to timestamp NOT NULL DEFAULT '9999-12-31',
334
- recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now()),
335
-
336
- CONSTRAINT #{table}_from_before_to CHECK (valid_from < valid_to),
337
-
338
- CONSTRAINT #{table}_overlapping_times EXCLUDE USING gist (
339
- box(
340
- point( extract( epoch FROM valid_from), id ),
341
- point( extract( epoch FROM valid_to - INTERVAL '1 millisecond'), id )
342
- ) with &&
343
- )
478
+ recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now())
344
479
  ) INHERITS ( #{parent} )
345
480
  SQL
346
481
 
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
+
347
488
  # Inherited primary key
348
489
  execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
349
- # Snapshot of all entities at a specific point in time
350
- execute "CREATE INDEX #{table}_snapshot ON #{table} ( valid_from, valid_to )"
351
-
352
- if postgresql_version >= 90100
353
- # PG 9.1 makes efficient use of single-column indexes
354
- execute "CREATE INDEX #{table}_valid_from ON #{table} ( valid_from )"
355
- execute "CREATE INDEX #{table}_valid_to ON #{table} ( valid_to )"
356
- execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
357
- else
358
- # PG 9.0 requires multi-column indexes instead.
359
- #
360
- # Snapshot of a single entity at a specific point in time
361
- execute "CREATE INDEX #{table}_instance_snapshot ON #{table} ( id, valid_from, valid_to )"
362
- # History update
363
- execute "CREATE INDEX #{table}_instance_update ON #{table} ( id, valid_to )"
364
- # Single instance whole history
365
- execute "CREATE INDEX #{table}_instance_history ON #{table} ( id, recorded_at )"
366
- end
490
+
491
+ chrono_create_history_indexes_for(table, p_pkey)
492
+ end
493
+
494
+ def chrono_create_history_indexes_for(table, p_pkey = nil)
495
+ # Duplicate because of Migrate.upgrade_indexes_for
496
+ # TODO remove me.
497
+ p_pkey ||= primary_key("#{TEMPORAL_SCHEMA}.#{table}")
498
+
499
+ add_temporal_indexes table, :valid_from, :valid_to,
500
+ :on_current_schema => true
501
+
502
+ execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
503
+ execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
367
504
  end
368
505
 
369
506
  # Create the public view and its rewrite rules
@@ -378,31 +515,52 @@ module ChronoModel
378
515
  execute "DROP VIEW #{table}" if table_exists? table
379
516
  execute "CREATE VIEW #{table} AS SELECT *, xmin AS __xid FROM ONLY #{current}"
380
517
 
381
- columns = columns(table).map(&:name)
382
- sequence = serial_sequence(current, pk) # For INSERT
383
- updates = columns.map {|c| "#{c} = new.#{c}"}.join(",\n") # For UPDATE
518
+ columns = columns(table).map{|c| quote_column_name(c.name)}
519
+ columns.delete(quote_column_name(pk))
384
520
 
385
- columns.delete(pk)
521
+ updates = columns.map {|c| "#{c} = new.#{c}"}.join(",\n")
386
522
 
387
523
  fields, values = columns.join(', '), columns.map {|c| "new.#{c}"}.join(', ')
524
+ fields_with_pk, values_with_pk = "#{pk}, " << fields, "new.#{pk}, " << values
388
525
 
389
- # INSERT - inert data both in the temporal table and in the history one.
526
+ # INSERT - insert data both in the temporal table and in the history one.
527
+ #
528
+ # A trigger is required if there is a serial ID column, as rules by
529
+ # design cannot handle the following case:
390
530
  #
391
- if sequence.present?
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?
392
544
  execute <<-SQL
393
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
+ );
394
549
 
395
- INSERT INTO #{current} ( #{fields} ) VALUES ( #{values} );
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;
396
557
 
397
- INSERT INTO #{history} ( #{pk}, #{fields}, valid_from )
398
- VALUES ( currval('#{sequence}'), #{values}, timezone('UTC', now()) )
399
- RETURNING #{pk}, #{fields}, xmin
400
- )
558
+ DROP TRIGGER IF EXISTS history_ins ON #{current};
559
+
560
+ CREATE TRIGGER history_ins AFTER INSERT ON #{current}
561
+ FOR EACH ROW EXECUTE PROCEDURE #{current}_ins();
401
562
  SQL
402
563
  else
403
- fields_with_pk = "#{pk}, " << fields
404
- values_with_pk = "new.#{pk}, " << values
405
-
406
564
  execute <<-SQL
407
565
  CREATE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
408
566
 
@@ -410,7 +568,7 @@ module ChronoModel
410
568
 
411
569
  INSERT INTO #{history} ( #{fields_with_pk}, valid_from )
412
570
  VALUES ( #{values_with_pk}, timezone('UTC', now()) )
413
- RETURNING #{fields_with_pk}
571
+ RETURNING #{fields_with_pk}, xmin
414
572
  )
415
573
  SQL
416
574
  end
@@ -501,6 +659,31 @@ module ChronoModel
501
659
  end
502
660
  end
503
661
 
662
+ # Generic alteration of history tables, where changes have to be
663
+ # propagated both on the temporal table and the history one.
664
+ #
665
+ # Internally, the :on_current_schema bypasses the +is_chrono?+
666
+ # check, as some temporal indexes and constraints are created
667
+ # only on the history table, and the creation methods already
668
+ # run scoped into the correct schema.
669
+ #
670
+ def chrono_alter_index(table_name, options)
671
+ if is_chrono?(table_name) && !options[:on_current_schema]
672
+ _on_temporal_schema { yield }
673
+ _on_history_schema { yield }
674
+ else
675
+ yield
676
+ end
677
+ end
678
+
679
+ def chrono_alter_constraint(table_name, options)
680
+ if is_chrono?(table_name) && !options[:on_current_schema]
681
+ _on_temporal_schema { yield }
682
+ else
683
+ yield
684
+ end
685
+ end
686
+
504
687
  def _on_temporal_schema(nesting = true, &block)
505
688
  on_schema(TEMPORAL_SCHEMA, nesting, &block)
506
689
  end
@@ -508,6 +691,14 @@ module ChronoModel
508
691
  def _on_history_schema(nesting = true, &block)
509
692
  on_schema(HISTORY_SCHEMA, nesting, &block)
510
693
  end
694
+
695
+ def translate_exception(exception, message)
696
+ if exception.message =~ /conflicting key value violates exclusion constraint/
697
+ ActiveRecord::RecordNotUnique.new(message, exception)
698
+ else
699
+ super
700
+ end
701
+ end
511
702
  end
512
703
 
513
704
  end