chrono_model 0.4.0 → 0.5.0.beta
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/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
|