chrono_model 0.5.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
|