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 +1 -1
- data/README.md +64 -35
- data/README.sql +73 -27
- data/lib/chrono_model/adapter.rb +235 -44
- data/lib/chrono_model/patches.rb +28 -75
- data/lib/chrono_model/railtie.rb +0 -25
- data/lib/chrono_model/time_gate.rb +36 -0
- data/lib/chrono_model/time_machine.rb +345 -122
- data/lib/chrono_model/utils.rb +89 -0
- data/lib/chrono_model/version.rb +1 -1
- data/lib/chrono_model.rb +2 -7
- data/spec/adapter_spec.rb +74 -7
- data/spec/support/connection.rb +1 -1
- data/spec/support/helpers.rb +20 -1
- data/spec/time_machine_spec.rb +216 -12
- metadata +11 -9
data/Gemfile.lock
CHANGED
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 `
|
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.
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
125
|
-
SELECT
|
126
|
-
|
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
|
-
|
135
|
-
|
136
|
-
|
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
|
-
|
149
|
-
|
150
|
-
)
|
151
|
-
|
152
|
-
)
|
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
|
-
*
|
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
|
-
*
|
172
|
-
|
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
|
178
|
-
|
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(
|
34
|
-
point(
|
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
|
40
|
+
create index country_inherit_pkey on history.countries ( id )
|
41
41
|
|
42
|
-
-- Snapshot of
|
43
|
-
create index country_snapshot
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
-
--
|
49
|
-
create index
|
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
|
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
|
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
|
-
--
|
64
|
-
--
|
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
|
69
|
-
|
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
|
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
|
-
|
79
|
-
|
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
|
-
|
86
|
-
|
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
|
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
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -28,7 +28,10 @@ module ChronoModel
|
|
28
28
|
return super unless options[:temporal]
|
29
29
|
|
30
30
|
if options[:id] == false
|
31
|
-
|
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
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
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
|
382
|
-
|
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.
|
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 -
|
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
|
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
|
-
|
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
|
-
|
398
|
-
|
399
|
-
|
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
|