chrono_model 0.5.3 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +1 -1
- data/.travis.yml +7 -0
- data/Gemfile +10 -1
- data/LICENSE +3 -1
- data/README.md +239 -136
- data/README.sql +108 -94
- data/chrono_model.gemspec +5 -4
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +42 -0
- data/lib/chrono_model.rb +0 -8
- data/lib/chrono_model/adapter.rb +346 -212
- data/lib/chrono_model/patches.rb +21 -8
- data/lib/chrono_model/railtie.rb +1 -13
- data/lib/chrono_model/time_gate.rb +2 -2
- data/lib/chrono_model/time_machine.rb +153 -87
- data/lib/chrono_model/utils.rb +35 -8
- data/lib/chrono_model/version.rb +1 -1
- data/spec/adapter_spec.rb +154 -14
- data/spec/config.yml.example +1 -0
- data/spec/json_ops_spec.rb +48 -0
- data/spec/support/connection.rb +4 -9
- data/spec/support/helpers.rb +27 -2
- data/spec/support/matchers/column.rb +5 -2
- data/spec/support/matchers/index.rb +4 -0
- data/spec/support/matchers/schema.rb +4 -0
- data/spec/support/matchers/table.rb +94 -21
- data/spec/time_machine_spec.rb +62 -28
- data/spec/time_query_spec.rb +227 -0
- data/sql/json_ops.sql +56 -0
- data/sql/uninstall-json_ops.sql +24 -0
- metadata +44 -18
- data/lib/chrono_model/compatibility.rb +0 -31
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
|
13
|
-
name
|
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
|
-
|
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
|
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
|
-
|
45
|
-
|
46
|
-
|
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,
|
60
|
-
-- the
|
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
|
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
|
54
|
+
-- INSERT - insert data both in the current data table and in the history one.
|
67
55
|
--
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
-
--
|
75
|
-
--
|
76
|
-
--
|
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
|
-
--
|
83
|
+
-- If the update doesn't change the data, it is skipped and the trigger
|
84
|
+
-- returns NULL.
|
80
85
|
--
|
81
|
-
--
|
86
|
+
-- By default, history is not recorded if only the updated_at field
|
87
|
+
-- is changed.
|
82
88
|
--
|
83
|
-
create
|
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
|
-
|
86
|
-
|
87
|
-
);
|
100
|
+
_old := row(old.name);
|
101
|
+
_new := row(new.name);
|
88
102
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
98
|
-
|
129
|
+
create trigger chronomodel_update
|
130
|
+
instead of update on temporal.countries
|
131
|
+
for each row execute procedure chronomodel_countries_update();
|
99
132
|
|
100
|
-
--
|
101
|
-
--
|
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
|
-
|
106
|
-
|
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
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
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
|
data/chrono_model.gemspec
CHANGED
@@ -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 = [
|
6
|
-
gem.email = [
|
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 =
|
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", "~>
|
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
|
+
|
data/lib/chrono_model.rb
CHANGED
@@ -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
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -14,10 +14,15 @@ module ChronoModel
|
|
14
14
|
# The schema holding historical data
|
15
15
|
HISTORY_SCHEMA = 'history'
|
16
16
|
|
17
|
-
#
|
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 >=
|
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}')
|
114
|
-
|
115
|
-
|
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
|
-
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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.
|
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
|
-
# `
|
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
|
-
# {
|
318
|
+
# index_{table}_temporal_on_{range / lower_range / upper_range}
|
285
319
|
#
|
286
|
-
def add_temporal_indexes(table,
|
287
|
-
|
288
|
-
temporal_index_names(table,
|
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 #{
|
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
|
330
|
+
# tables, by UPDATE / DELETE triggers.
|
302
331
|
#
|
303
|
-
execute "CREATE INDEX #{
|
304
|
-
execute "CREATE INDEX #{
|
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,
|
309
|
-
indexes = temporal_index_names(table,
|
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,
|
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
|
-
|
344
|
-
|
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
|
-
|
347
|
-
|
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
|
-
|
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
|
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
|
-
#{
|
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,
|
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(
|
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
|
451
|
-
|
452
|
-
|
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
|
-
#
|
457
|
-
#
|
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
|
-
|
460
|
-
|
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
|
464
|
-
|
465
|
-
|
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
|
-
|
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
|
-
|
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, :
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
529
|
-
#
|
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
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
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
|
-
|
677
|
+
INSERT INTO #{current} ( #{pk}, #{fields} )
|
678
|
+
VALUES ( NEW.#{pk}, #{values} );
|
559
679
|
|
560
|
-
|
561
|
-
|
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
|
-
|
683
|
+
RETURN NEW;
|
684
|
+
END;
|
685
|
+
$$ LANGUAGE plpgsql;
|
568
686
|
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
)
|
573
|
-
|
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
|
-
#
|
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
|
-
#
|
590
|
-
#
|
591
|
-
#
|
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
|
597
|
-
|
598
|
-
|
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
|
-
|
601
|
-
|
713
|
+
_old := row(#{journal.map {|c| "OLD.#{c}" }.join(', ')});
|
714
|
+
_new := row(#{journal.map {|c| "NEW.#{c}" }.join(', ')});
|
602
715
|
|
603
|
-
|
604
|
-
|
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
|
-
|
607
|
-
|
608
|
-
)
|
609
|
-
SQL
|
721
|
+
_now := timezone('UTC', now());
|
722
|
+
_hid := NULL;
|
610
723
|
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
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
|
-
|
621
|
-
|
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
|
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
|
-
|
634
|
-
|
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
|
-
|
639
|
-
|
762
|
+
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
763
|
+
WHERE #{pk} = old.#{pk} AND upper_inf(validity);
|
640
764
|
|
641
|
-
|
642
|
-
|
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
|
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
|
|