chrono_model 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/Gemfile +10 -0
- data/LICENSE +22 -0
- data/README.md +173 -0
- data/README.sql +101 -0
- data/Rakefile +7 -0
- data/chrono_model.gemspec +20 -0
- data/lib/chrono_model.rb +34 -0
- data/lib/chrono_model/adapter.rb +423 -0
- data/lib/chrono_model/compatibility.rb +31 -0
- data/lib/chrono_model/patches.rb +104 -0
- data/lib/chrono_model/railtie.rb +41 -0
- data/lib/chrono_model/time_machine.rb +214 -0
- data/lib/chrono_model/version.rb +3 -0
- data/spec/adapter_spec.rb +398 -0
- data/spec/config.yml.example +7 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/connection.rb +74 -0
- data/spec/support/helpers.rb +123 -0
- data/spec/support/matchers/base.rb +59 -0
- data/spec/support/matchers/column.rb +83 -0
- data/spec/support/matchers/index.rb +61 -0
- data/spec/support/matchers/schema.rb +31 -0
- data/spec/support/matchers/table.rb +171 -0
- data/spec/time_machine_spec.rb +299 -0
- metadata +105 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Marcello Barnaba <m.barnaba@ifad.org>
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
# ChronoModel
|
2
|
+
|
3
|
+
A temporal database system on PostgreSQL using
|
4
|
+
[table inheritance](http://www.postgresql.org/docs/9.0/static/ddl-inherit.html) and
|
5
|
+
[the rule system](http://www.postgresql.org/docs/9.0/static/rules-update.html).
|
6
|
+
|
7
|
+
This is a data structure for a
|
8
|
+
[Slowly-Changing Dimension Type 2](http://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2)
|
9
|
+
temporal database, implemented using only [PostgreSQL](http://www.postgresql.org) >= 9.0 features.
|
10
|
+
|
11
|
+
All the history recording is done inside the database system, freeing the application code from
|
12
|
+
having to deal with it.
|
13
|
+
|
14
|
+
The application model is backed by an updatable view that behaves exactly like a plain table, while
|
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).
|
17
|
+
|
18
|
+
Current data is hold in a table in the `current` [schema](http://www.postgresql.org/docs/9.0/static/ddl-schemas.html),
|
19
|
+
while history in hold in another table in the `history` schema. The latter
|
20
|
+
[inherits](http://www.postgresql.org/docs/9.0/static/ddl-inherit.html) from the former, to get
|
21
|
+
automated schema updates for free. Partitioning of history is even possible but not implemented
|
22
|
+
yet.
|
23
|
+
|
24
|
+
The updatable view is created in the default `public` schema, making it visible to Active Record.
|
25
|
+
|
26
|
+
All Active Record schema migration statements are decorated with code that handles the temporal
|
27
|
+
structure by e.g. keeping the view rules in sync or dropping/recreating it when required by your
|
28
|
+
migrations. A schema dumper is available as well.
|
29
|
+
|
30
|
+
Data extraction at a single point in time and even `JOIN`s between temporal and non-temporal data
|
31
|
+
is implemented using
|
32
|
+
[Common Table Expressions](http://www.postgresql.org/docs/9.0/static/queries-with.html)
|
33
|
+
(WITH queries) and a `WHERE date >= valid_from AND date < valid_to` clause, generated automatically
|
34
|
+
by the provided `TimeMachine` module to be included in your models.
|
35
|
+
|
36
|
+
Optimal temporal timestamps indexing is provided for both PostgreSQL 9.0 and 9.1 query planners.
|
37
|
+
|
38
|
+
All timestamps are (forcibly) stored in the UTC time zone, bypassing the `AR::Base.config.default_timezone`
|
39
|
+
setting.
|
40
|
+
|
41
|
+
See [README.sql](https://github.com/ifad/chronomodel/blob/master/README.sql) for the plain SQL
|
42
|
+
defining this temporal schema for a single table.
|
43
|
+
|
44
|
+
|
45
|
+
## Requirements
|
46
|
+
|
47
|
+
* Ruby >= 1.9.2
|
48
|
+
* Active Record >= 3.2
|
49
|
+
* PostgreSQL >= 9.0
|
50
|
+
|
51
|
+
|
52
|
+
## Installation
|
53
|
+
|
54
|
+
Add this line to your application's Gemfile:
|
55
|
+
|
56
|
+
gem 'chrono_model', :git => 'git://github.com/ifad/chronomodel'
|
57
|
+
|
58
|
+
And then execute:
|
59
|
+
|
60
|
+
$ bundle
|
61
|
+
|
62
|
+
|
63
|
+
## Schema creation
|
64
|
+
|
65
|
+
This library hooks all `ActiveRecord::Migration` methods to make them temporal aware.
|
66
|
+
|
67
|
+
The only option added is `:temporal => true` to `create_table`:
|
68
|
+
|
69
|
+
create_table :countries, :temporal => true do |t|
|
70
|
+
t.string :common_name
|
71
|
+
t.references :currency
|
72
|
+
# ...
|
73
|
+
end
|
74
|
+
|
75
|
+
That'll create the _current_, its _history_ child table and the _public_ view.
|
76
|
+
Every other housekeeping of the temporal structure is handled behind the scenes
|
77
|
+
by the other schema statements. E.g.:
|
78
|
+
|
79
|
+
* `rename_table` - renames tables, views, sequences, indexes and rules
|
80
|
+
* `drop_table` - drops the temporal table and all dependant objects
|
81
|
+
* `add_column` - adds the column to the current table and updates rules
|
82
|
+
* `rename_column` - renames the current table column and updates the rules
|
83
|
+
* `remove_column` - removes the current table column and updates the rules
|
84
|
+
* `add_index` - creates the index in the history table as well
|
85
|
+
* `remove_index` - removes the index from the history table as well
|
86
|
+
|
87
|
+
|
88
|
+
## Data querying
|
89
|
+
|
90
|
+
A model backed by a temporal view will behave like any other model backed by a
|
91
|
+
plain table. If you want to do as-of-date queries, you need to include the
|
92
|
+
`ChronoModel::TimeMachine` module in your model.
|
93
|
+
|
94
|
+
module Country < ActiveRecord::Base
|
95
|
+
include ChronoModel::TimeMachine
|
96
|
+
|
97
|
+
has_many :compositions
|
98
|
+
end
|
99
|
+
|
100
|
+
This will make an `as_of` class method available to your model. E.g.:
|
101
|
+
|
102
|
+
Country.as_of(1.year.ago)
|
103
|
+
|
104
|
+
Will execute:
|
105
|
+
|
106
|
+
WITH countries AS (
|
107
|
+
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
|
108
|
+
) SELECT * FROM countries
|
109
|
+
|
110
|
+
This work on associations using temporal extensions as well:
|
111
|
+
|
112
|
+
Country.as_of(1.year.ago).first.compositions
|
113
|
+
|
114
|
+
Will execute:
|
115
|
+
|
116
|
+
WITH countries AS (
|
117
|
+
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
|
118
|
+
) SELECT * FROM countries LIMIT 1
|
119
|
+
|
120
|
+
WITH compositions AS (
|
121
|
+
SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
|
122
|
+
) SELECT * FROM compositions WHERE country_id = X
|
123
|
+
|
124
|
+
And `.joins` works as well:
|
125
|
+
|
126
|
+
Country.as_of(1.month.ago).joins(:compositions)
|
127
|
+
|
128
|
+
Will execute:
|
129
|
+
|
130
|
+
WITH countries AS (
|
131
|
+
SELECT * FROM history.countries WHERE #{1.year.ago.utc} >= valid_from AND #{1.year.ago.utc} < valid_to
|
132
|
+
), compositions AS (
|
133
|
+
SELECT * FROM history.countries WHERE #{above_timestamp} >= valid_from AND #{above_timestamp} < valid_to
|
134
|
+
) SELECT * FROM countries INNER JOIN countries ON compositions.country_id = countries.id
|
135
|
+
|
136
|
+
More methods are provided, see the
|
137
|
+
[TimeMachine](https://github.com/ifad/chronomodel/blob/master/lib/chrono_model/time_machine.rb) source
|
138
|
+
for more information.
|
139
|
+
|
140
|
+
|
141
|
+
## Running tests
|
142
|
+
|
143
|
+
You need a running Postgresql instance. Create `spec/config.yml` with the
|
144
|
+
connection authentication details (use `spec/config.yml.example` as template).
|
145
|
+
|
146
|
+
Run `rake`. SQL queries are logged to `spec/debug.log`. If you want to see
|
147
|
+
them in your output, use `rake VERBOSE=true`.
|
148
|
+
|
149
|
+
## Caveats
|
150
|
+
|
151
|
+
* `.includes` still doesn't work, but it'll fixed soon.
|
152
|
+
|
153
|
+
* Some monkeypatching has been necessary both to `ActiveRecord::Relation` and
|
154
|
+
to `Arel::Visitors::ToSql` to fix a bug with `WITH` queries generation. This
|
155
|
+
will be reported to the upstream with a pull request after extensive testing.
|
156
|
+
|
157
|
+
* The migration statements extension is implemented using a Man-in-the-middle
|
158
|
+
class that inherits from the PostgreSQL adapter, and that relies on some
|
159
|
+
private APIs. This should be made more maintainable, maybe by implementing
|
160
|
+
an extension framework for connection adapters. This library will (clearly)
|
161
|
+
never be merged into Rails, as it is against its principle of treating the
|
162
|
+
SQL database as a dummy data store.
|
163
|
+
|
164
|
+
* The schema dumper is WAY TOO hacky.
|
165
|
+
|
166
|
+
|
167
|
+
## Contributing
|
168
|
+
|
169
|
+
1. Fork it
|
170
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
171
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
172
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
173
|
+
5. Create new Pull Request
|
data/README.sql
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
------------------------------------------------------
|
2
|
+
-- Temporal schema for an example "countries" relation
|
3
|
+
--
|
4
|
+
-- http://github.com/ifad/chronomodel
|
5
|
+
--
|
6
|
+
create schema temporal; -- schema containing all temporal tables
|
7
|
+
create schema history; -- schema containing all history tables
|
8
|
+
|
9
|
+
-- Current countries data - nothing special
|
10
|
+
--
|
11
|
+
create table temporal.countries (
|
12
|
+
id serial primary key,
|
13
|
+
name varchar
|
14
|
+
);
|
15
|
+
|
16
|
+
-- Countries historical data.
|
17
|
+
--
|
18
|
+
-- Inheritance is used to avoid duplicating the schema from the main table.
|
19
|
+
-- Please note that columns on the main table cannot be dropped, and other caveats
|
20
|
+
-- http://www.postgresql.org/docs/9.0/static/ddl-inherit.html#DDL-INHERIT-CAVEATS
|
21
|
+
--
|
22
|
+
create table history.countries (
|
23
|
+
|
24
|
+
hid serial primary key,
|
25
|
+
valid_from timestamp not null,
|
26
|
+
valid_to timestamp not null default '9999-12-31',
|
27
|
+
recorded_at timestamp not null default now(),
|
28
|
+
|
29
|
+
constraint from_before_to check (valid_from < valid_to),
|
30
|
+
|
31
|
+
constraint overlapping_times exclude using gist (
|
32
|
+
box(
|
33
|
+
point( extract( epoch from valid_from), id ),
|
34
|
+
point( extract( epoch from valid_to - interval '1 millisecond'), id )
|
35
|
+
) with &&
|
36
|
+
)
|
37
|
+
) inherits ( temporal.countries );
|
38
|
+
|
39
|
+
-- Inherited primary key
|
40
|
+
create index country_inherit_pkey ON countries ( id )
|
41
|
+
|
42
|
+
-- Snapshot of all entities at a specific point in time
|
43
|
+
create index country_snapshot on history.countries ( valid_from, valid_to )
|
44
|
+
|
45
|
+
-- Snapshot of a single entity at a specific point in time
|
46
|
+
create index country_instance_snapshot on history.countries ( id, valid_from, valid_to )
|
47
|
+
|
48
|
+
-- History update
|
49
|
+
create index country_instance_update on history.countries ( id, valid_to )
|
50
|
+
|
51
|
+
-- Single instance whole history
|
52
|
+
create index country_instance_history on history.countries ( id, recorded_at )
|
53
|
+
|
54
|
+
|
55
|
+
-- The countries view, what the Rails' application ORM will actually CRUD on, and
|
56
|
+
-- the core of the temporal updates.
|
57
|
+
--
|
58
|
+
-- SELECT - return only current data
|
59
|
+
--
|
60
|
+
create view public.countries as select * from only temporal.countries;
|
61
|
+
|
62
|
+
-- INSERT - insert data both in the current data table and in the history table.
|
63
|
+
-- Return data from the history table as the RETURNING clause must be the last
|
64
|
+
-- one in the rule.
|
65
|
+
create rule countries_ins as on insert to public.countries do instead (
|
66
|
+
insert into temporal.countries ( name ) values ( new.name );
|
67
|
+
|
68
|
+
insert into history.countries ( id, name, valid_from )
|
69
|
+
values ( currval('temporal.countries_id_seq'), new.name, now() )
|
70
|
+
returning ( new.name )
|
71
|
+
);
|
72
|
+
|
73
|
+
-- UPDATE - set the last history entry validity to now, save the current data in
|
74
|
+
-- a new history entry and update the current table with the new data.
|
75
|
+
--
|
76
|
+
create rule countries_upd as on update to countries do instead (
|
77
|
+
update history.countries
|
78
|
+
set valid_to = now()
|
79
|
+
where id = old.id and valid_to = '9999-12-31';
|
80
|
+
|
81
|
+
insert into history.countries ( id, name, valid_from )
|
82
|
+
values ( old.id, new.name, now() );
|
83
|
+
|
84
|
+
update only temporal.countries
|
85
|
+
set name = new.name
|
86
|
+
where id = old.id
|
87
|
+
);
|
88
|
+
|
89
|
+
-- DELETE - save the current data in the history and eventually delete the data
|
90
|
+
-- from the current table.
|
91
|
+
--
|
92
|
+
create rule countries_del as on delete to countries do instead (
|
93
|
+
update history.countries
|
94
|
+
set valid_to = now()
|
95
|
+
where id = old.id and valid_to = '9999-12-31';
|
96
|
+
|
97
|
+
delete from only temporal.countries
|
98
|
+
where temporal.countries.id = old.id
|
99
|
+
);
|
100
|
+
|
101
|
+
-- EOF
|
data/Rakefile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/chrono_model/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Marcello Barnaba"]
|
6
|
+
gem.email = ["vjt@openssl.it"]
|
7
|
+
gem.description = %q{Give your models as-of date temporal extensions. Built entirely for PostgreSQL >= 9.0}
|
8
|
+
gem.summary = %q{Temporal extensions (SCD Type II) for Active Record}
|
9
|
+
gem.homepage = "http://github.com/ifad/chronomodel"
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "chrono_model"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = ChronoModel::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency "activerecord", "~> 3.2"
|
19
|
+
gem.add_dependency "pg"
|
20
|
+
end
|
data/lib/chrono_model.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'chrono_model/version'
|
2
|
+
require 'chrono_model/adapter'
|
3
|
+
require 'chrono_model/compatibility'
|
4
|
+
require 'chrono_model/patches'
|
5
|
+
require 'chrono_model/time_machine'
|
6
|
+
|
7
|
+
module ChronoModel
|
8
|
+
class Error < ActiveRecord::ActiveRecordError #:nodoc:
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
if defined?(Rails)
|
13
|
+
require 'chrono_model/railtie'
|
14
|
+
end
|
15
|
+
|
16
|
+
# Install it.
|
17
|
+
silence_warnings do
|
18
|
+
# Replace AR's PG adapter with the ChronoModel one. This (dirty) approach is
|
19
|
+
# required because the PG adapter defines +add_column+ itself, thus making
|
20
|
+
# impossible to use super() in overridden Module methods.
|
21
|
+
#
|
22
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter = ChronoModel::Adapter
|
23
|
+
|
24
|
+
# We need to override the "scoped" method on AR::Association for temporal
|
25
|
+
# associations to work as well
|
26
|
+
ActiveRecord::Associations::Association = ChronoModel::Patches::Association
|
27
|
+
|
28
|
+
# This implements correct WITH syntax on PostgreSQL
|
29
|
+
Arel::Visitors::PostgreSQL = ChronoModel::Patches::Visitor
|
30
|
+
|
31
|
+
# This adds .with support to ActiveRecord::Relation
|
32
|
+
ActiveRecord::Relation.instance_eval { include ChronoModel::Patches::QueryMethods }
|
33
|
+
ActiveRecord::Base.extend ChronoModel::Patches::Querying
|
34
|
+
end
|
@@ -0,0 +1,423 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'active_record/connection_adapters/postgresql_adapter'
|
3
|
+
|
4
|
+
module ChronoModel
|
5
|
+
|
6
|
+
# This class implements all ActiveRecord::ConnectionAdapters::SchemaStatements
|
7
|
+
# methods adding support for temporal extensions. It inherits from the Postgres
|
8
|
+
# adapter for a clean override of its methods using super.
|
9
|
+
#
|
10
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
11
|
+
TEMPORAL_SCHEMA = 'temporal' # The schema holding current data
|
12
|
+
HISTORY_SCHEMA = 'history' # The schema holding historical data
|
13
|
+
|
14
|
+
def chrono_supported?
|
15
|
+
postgresql_version >= 90000
|
16
|
+
end
|
17
|
+
|
18
|
+
# Creates the given table, possibly creating the temporal schema
|
19
|
+
# objects if the `:temporal` option is given and set to true.
|
20
|
+
#
|
21
|
+
def create_table(table_name, options = {})
|
22
|
+
# No temporal features requested, skip
|
23
|
+
return super unless options[:temporal]
|
24
|
+
|
25
|
+
if options[:id] == false
|
26
|
+
raise Error, "Temporal tables require a primary key."
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create required schemas
|
30
|
+
chrono_create_schemas!
|
31
|
+
|
32
|
+
transaction do
|
33
|
+
_on_temporal_schema { super }
|
34
|
+
_on_history_schema { chrono_create_history_for(table_name) }
|
35
|
+
|
36
|
+
chrono_create_view_for(table_name)
|
37
|
+
|
38
|
+
TableCache.add! table_name
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# If renaming a temporal table, rename the history and view as well.
|
43
|
+
#
|
44
|
+
def rename_table(name, new_name)
|
45
|
+
return super unless is_chrono?(name)
|
46
|
+
|
47
|
+
clear_cache!
|
48
|
+
|
49
|
+
transaction do
|
50
|
+
[TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
|
51
|
+
on_schema(schema) do
|
52
|
+
seq = serial_sequence(name, primary_key(name))
|
53
|
+
new_seq = seq.sub(name.to_s, new_name.to_s).split('.').last
|
54
|
+
|
55
|
+
execute "ALTER SEQUENCE #{seq} RENAME TO #{new_seq}"
|
56
|
+
execute "ALTER TABLE #{name} RENAME TO #{new_name}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
execute "ALTER VIEW #{name} RENAME TO #{new_name}"
|
61
|
+
|
62
|
+
TableCache.del! name
|
63
|
+
TableCache.add! new_name
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# If changing a temporal table, redirect the change to the table in the
|
68
|
+
# temporal schema and recreate views.
|
69
|
+
#
|
70
|
+
# If the `:temporal` option is specified, enables or disables temporal
|
71
|
+
# features on the given table. Please note that you'll lose your history
|
72
|
+
# when demoting a temporal table to a plain one.
|
73
|
+
#
|
74
|
+
def change_table(table_name, options = {}, &block)
|
75
|
+
transaction do
|
76
|
+
|
77
|
+
# Add an empty proc to support calling change_table without a block.
|
78
|
+
#
|
79
|
+
block ||= proc { }
|
80
|
+
|
81
|
+
if options[:temporal] == true
|
82
|
+
if !is_chrono?(table_name)
|
83
|
+
# Add temporal features to this table
|
84
|
+
#
|
85
|
+
execute "ALTER TABLE #{table_name} SET SCHEMA #{TEMPORAL_SCHEMA}"
|
86
|
+
_on_history_schema { chrono_create_history_for(table_name) }
|
87
|
+
chrono_create_view_for(table_name)
|
88
|
+
|
89
|
+
TableCache.add! table_name
|
90
|
+
end
|
91
|
+
|
92
|
+
chrono_alter(table_name) { super table_name, options, &block }
|
93
|
+
else
|
94
|
+
if options[:temporal] == false && is_chrono?(table_name)
|
95
|
+
# Remove temporal features from this table
|
96
|
+
#
|
97
|
+
execute "DROP VIEW #{table_name}"
|
98
|
+
_on_history_schema { execute "DROP TABLE #{table_name}" }
|
99
|
+
|
100
|
+
default_schema = select_value 'SELECT current_schema()'
|
101
|
+
_on_temporal_schema do
|
102
|
+
execute "ALTER TABLE #{table_name} SET SCHEMA #{default_schema}"
|
103
|
+
end
|
104
|
+
|
105
|
+
TableCache.del! table_name
|
106
|
+
end
|
107
|
+
|
108
|
+
super table_name, options, &block
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# If dropping a temporal table, drops it from the temporal schema
|
114
|
+
# adding the CASCADE option so to delete the history, view and rules.
|
115
|
+
#
|
116
|
+
def drop_table(table_name, *)
|
117
|
+
return super unless is_chrono?(table_name)
|
118
|
+
|
119
|
+
_on_temporal_schema { execute "DROP TABLE #{table_name} CASCADE" }
|
120
|
+
|
121
|
+
TableCache.del! table_name
|
122
|
+
end
|
123
|
+
|
124
|
+
# If adding an index to a temporal table, add it to the one in the
|
125
|
+
# temporal schema and to the history one. If the `:unique` option is
|
126
|
+
# present, it is removed from the index created in the history table.
|
127
|
+
#
|
128
|
+
def add_index(table_name, column_name, options = {})
|
129
|
+
return super unless is_chrono?(table_name)
|
130
|
+
|
131
|
+
transaction do
|
132
|
+
_on_temporal_schema { super }
|
133
|
+
|
134
|
+
# Uniqueness constraints do not make sense in the history table
|
135
|
+
options = options.dup.tap {|o| o.delete(:unique)} if options[:unique].present?
|
136
|
+
|
137
|
+
_on_history_schema { super table_name, column_name, options }
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# If removing an index from a temporal table, remove it both from the
|
142
|
+
# temporal and the history schemas.
|
143
|
+
#
|
144
|
+
def remove_index(table_name, *)
|
145
|
+
return super unless is_chrono?(table_name)
|
146
|
+
|
147
|
+
transaction do
|
148
|
+
_on_temporal_schema { super }
|
149
|
+
_on_history_schema { super }
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# If adding a column to a temporal table, creates it in the table in
|
154
|
+
# the temporal schema and updates the view rules.
|
155
|
+
#
|
156
|
+
def add_column(table_name, *)
|
157
|
+
return super unless is_chrono?(table_name)
|
158
|
+
|
159
|
+
transaction do
|
160
|
+
# Add the column to the temporal table
|
161
|
+
_on_temporal_schema { super }
|
162
|
+
|
163
|
+
# Update the rules
|
164
|
+
chrono_create_view_for(table_name)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# If renaming a column of a temporal table, rename it in the table in
|
169
|
+
# the temporal schema and update the view rules.
|
170
|
+
#
|
171
|
+
def rename_column(table_name, *)
|
172
|
+
return super unless is_chrono?(table_name)
|
173
|
+
|
174
|
+
# Rename the column in the temporal table and in the view
|
175
|
+
transaction do
|
176
|
+
_on_temporal_schema { super }
|
177
|
+
super
|
178
|
+
|
179
|
+
# Update the rules
|
180
|
+
chrono_create_view_for(table_name)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# If removing a column from a temporal table, we are forced to drop the
|
185
|
+
# view, then change the column from the table in the temporal schema and
|
186
|
+
# eventually recreate the rules.
|
187
|
+
#
|
188
|
+
def change_column(table_name, *)
|
189
|
+
return super unless is_chrono?(table_name)
|
190
|
+
chrono_alter(table_name) { super }
|
191
|
+
end
|
192
|
+
|
193
|
+
# Change the default on the temporal schema table.
|
194
|
+
#
|
195
|
+
def change_column_default(table_name, *)
|
196
|
+
return super unless is_chrono?(table_name)
|
197
|
+
_on_temporal_schema { super }
|
198
|
+
end
|
199
|
+
|
200
|
+
# Change the null constraint on the temporal schema table.
|
201
|
+
#
|
202
|
+
def change_column_null(table_name, *)
|
203
|
+
return super unless is_chrono?(table_name)
|
204
|
+
_on_temporal_schema { super }
|
205
|
+
end
|
206
|
+
|
207
|
+
# If removing a column from a temporal table, we are forced to drop the
|
208
|
+
# view, then drop the column from the table in the temporal schema and
|
209
|
+
# eventually recreate the rules.
|
210
|
+
#
|
211
|
+
def remove_column(table_name, *)
|
212
|
+
return super unless is_chrono?(table_name)
|
213
|
+
chrono_alter(table_name) { super }
|
214
|
+
end
|
215
|
+
|
216
|
+
# Runs column_definitions, primary_key and indexes in the temporal schema,
|
217
|
+
# as the table there defined is the source for this information.
|
218
|
+
#
|
219
|
+
# Moreover, the PostgreSQLAdapter +indexes+ method uses current_schema(),
|
220
|
+
# thus this is the only (and cleanest) way to make injection work.
|
221
|
+
#
|
222
|
+
# Schema nesting is disabled on these calls, make sure to fetch metadata
|
223
|
+
# from the first caller's selected schema and not from the current one.
|
224
|
+
#
|
225
|
+
[:column_definitions, :primary_key, :indexes].each do |method|
|
226
|
+
define_method(method) do |table_name|
|
227
|
+
return super(table_name) unless is_chrono?(table_name)
|
228
|
+
_on_temporal_schema(false) { super(table_name) }
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# Evaluates the given block in the given +schema+ search path.
|
233
|
+
#
|
234
|
+
# By default, nested call are allowed, to disable this feature
|
235
|
+
# pass +false+ as the second parameter.
|
236
|
+
#
|
237
|
+
def on_schema(schema, nesting = true, &block)
|
238
|
+
@_on_schema_nesting = (@_on_schema_nesting || 0) + 1
|
239
|
+
|
240
|
+
if nesting || @_on_schema_nesting == 1
|
241
|
+
old_path = self.schema_search_path
|
242
|
+
self.schema_search_path = schema
|
243
|
+
end
|
244
|
+
|
245
|
+
block.call
|
246
|
+
|
247
|
+
ensure
|
248
|
+
if (nesting || @_on_schema_nesting == 1)
|
249
|
+
|
250
|
+
# If the transaction is aborted, any execute() call will raise
|
251
|
+
# "transaction is aborted errors" - thus calling the Adapter's
|
252
|
+
# setter won't update the memoized variable.
|
253
|
+
#
|
254
|
+
# Here we reset it to +nil+ to refresh it on the next call, as
|
255
|
+
# there is no way to know which path will be restored when the
|
256
|
+
# transaction ends.
|
257
|
+
#
|
258
|
+
if @connection.transaction_status == PGconn::PQTRANS_INERROR
|
259
|
+
@schema_search_path = nil
|
260
|
+
else
|
261
|
+
self.schema_search_path = old_path
|
262
|
+
end
|
263
|
+
end
|
264
|
+
@_on_schema_nesting -= 1
|
265
|
+
end
|
266
|
+
|
267
|
+
TableCache = (Class.new(HashWithIndifferentAccess) do
|
268
|
+
def all ; keys; ; end
|
269
|
+
def add! table ; self[table] = true ; end
|
270
|
+
def del! table ; self[table] = nil ; end
|
271
|
+
def fetch table ; self[table] ||= yield ; end
|
272
|
+
end).new
|
273
|
+
|
274
|
+
# Returns true if the given name references a temporal table.
|
275
|
+
#
|
276
|
+
def is_chrono?(table)
|
277
|
+
TableCache.fetch(table) do
|
278
|
+
_on_temporal_schema { table_exists?(table) } &&
|
279
|
+
_on_history_schema { table_exists?(table) }
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def chrono_create_schemas!
|
284
|
+
[TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
|
285
|
+
execute "CREATE SCHEMA #{schema}" unless schema_exists?(schema)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
private
|
290
|
+
# Create the history table in the history schema
|
291
|
+
def chrono_create_history_for(table)
|
292
|
+
parent = "#{TEMPORAL_SCHEMA}.#{table}"
|
293
|
+
p_pkey = primary_key(parent)
|
294
|
+
|
295
|
+
execute <<-SQL
|
296
|
+
CREATE TABLE #{table} (
|
297
|
+
hid SERIAL PRIMARY KEY,
|
298
|
+
valid_from timestamp NOT NULL,
|
299
|
+
valid_to timestamp NOT NULL DEFAULT '9999-12-31',
|
300
|
+
recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now()),
|
301
|
+
|
302
|
+
CONSTRAINT #{table}_from_before_to CHECK (valid_from < valid_to),
|
303
|
+
|
304
|
+
CONSTRAINT #{table}_overlapping_times EXCLUDE USING gist (
|
305
|
+
box(
|
306
|
+
point( extract( epoch FROM valid_from), id ),
|
307
|
+
point( extract( epoch FROM valid_to - INTERVAL '1 millisecond'), id )
|
308
|
+
) with &&
|
309
|
+
)
|
310
|
+
) INHERITS ( #{parent} )
|
311
|
+
SQL
|
312
|
+
|
313
|
+
# Inherited primary key
|
314
|
+
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
|
315
|
+
# Snapshot of all entities at a specific point in time
|
316
|
+
execute "CREATE INDEX #{table}_snapshot ON #{table} ( valid_from, valid_to )"
|
317
|
+
|
318
|
+
if postgresql_version >= 90100
|
319
|
+
# PG 9.1 makes efficient use of single-column indexes
|
320
|
+
execute "CREATE INDEX #{table}_valid_from ON #{table} ( valid_from )"
|
321
|
+
execute "CREATE INDEX #{table}_valid_to ON #{table} ( valid_to )"
|
322
|
+
execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
|
323
|
+
else
|
324
|
+
# PG 9.0 requires multi-column indexes instead.
|
325
|
+
#
|
326
|
+
# Snapshot of a single entity at a specific point in time
|
327
|
+
execute "CREATE INDEX #{table}_instance_snapshot ON #{table} ( id, valid_from, valid_to )"
|
328
|
+
# History update
|
329
|
+
execute "CREATE INDEX #{table}_instance_update ON #{table} ( id, valid_to )"
|
330
|
+
# Single instance whole history
|
331
|
+
execute "CREATE INDEX #{table}_instance_history ON #{table} ( id, recorded_at )"
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
# Create the public view and its rewrite rules
|
336
|
+
#
|
337
|
+
def chrono_create_view_for(table)
|
338
|
+
pk = primary_key(table)
|
339
|
+
current = [TEMPORAL_SCHEMA, table].join('.')
|
340
|
+
history = [HISTORY_SCHEMA, table].join('.')
|
341
|
+
|
342
|
+
# SELECT - return only current data
|
343
|
+
#
|
344
|
+
execute "CREATE OR REPLACE VIEW #{table} AS SELECT * FROM ONLY #{current}"
|
345
|
+
|
346
|
+
columns = columns(table).map(&:name)
|
347
|
+
sequence = serial_sequence(current, pk) # For INSERT
|
348
|
+
updates = columns.map {|c| "#{c} = new.#{c}"}.join(",\n") # For UPDATE
|
349
|
+
|
350
|
+
columns.delete(pk)
|
351
|
+
|
352
|
+
fields, values = columns.join(', '), columns.map {|c| "new.#{c}"}.join(', ')
|
353
|
+
|
354
|
+
# INSERT - inert data both in the temporal table and in the history one.
|
355
|
+
#
|
356
|
+
execute <<-SQL
|
357
|
+
CREATE OR REPLACE RULE #{table}_ins AS ON INSERT TO #{table} DO INSTEAD (
|
358
|
+
|
359
|
+
INSERT INTO #{current} ( #{fields} ) VALUES ( #{values} );
|
360
|
+
|
361
|
+
INSERT INTO #{history} ( #{pk}, #{fields}, valid_from )
|
362
|
+
VALUES ( currval('#{sequence}'), #{values}, timezone('UTC', now()) )
|
363
|
+
RETURNING #{pk}, #{fields}
|
364
|
+
)
|
365
|
+
SQL
|
366
|
+
|
367
|
+
# UPDATE - set the last history entry validity to now, save the current data
|
368
|
+
# in a new history entry and update the temporal table with the new data.
|
369
|
+
#
|
370
|
+
execute <<-SQL
|
371
|
+
CREATE OR REPLACE RULE #{table}_upd AS ON UPDATE TO #{table} DO INSTEAD (
|
372
|
+
|
373
|
+
UPDATE #{history} SET valid_to = timezone('UTC', now())
|
374
|
+
WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
|
375
|
+
|
376
|
+
INSERT INTO #{history} ( #{pk}, #{fields}, valid_from )
|
377
|
+
VALUES ( old.#{pk}, #{values}, timezone('UTC', now()) );
|
378
|
+
|
379
|
+
UPDATE ONLY #{current} SET #{updates}
|
380
|
+
WHERE #{pk} = old.#{pk}
|
381
|
+
)
|
382
|
+
SQL
|
383
|
+
|
384
|
+
# DELETE - save the current data in the history and eventually delete the data
|
385
|
+
# from the temporal table.
|
386
|
+
#
|
387
|
+
execute <<-SQL
|
388
|
+
CREATE OR REPLACE RULE #{table}_del AS ON DELETE TO #{table} DO INSTEAD (
|
389
|
+
|
390
|
+
UPDATE #{history} SET valid_to = timezone('UTC', now())
|
391
|
+
WHERE #{pk} = old.#{pk} AND valid_to = '9999-12-31';
|
392
|
+
|
393
|
+
DELETE FROM ONLY #{current}
|
394
|
+
WHERE #{current}.#{pk} = old.#{pk}
|
395
|
+
)
|
396
|
+
SQL
|
397
|
+
end
|
398
|
+
|
399
|
+
# In destructive changes, such as removing columns or changing column
|
400
|
+
# types, the view must be dropped and recreated, while the change has
|
401
|
+
# to be applied to the table in the temporal schema.
|
402
|
+
#
|
403
|
+
def chrono_alter(table_name)
|
404
|
+
transaction do
|
405
|
+
execute "DROP VIEW #{table_name}"
|
406
|
+
|
407
|
+
_on_temporal_schema { yield }
|
408
|
+
|
409
|
+
# Recreate the rules
|
410
|
+
chrono_create_view_for(table_name)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def _on_temporal_schema(nesting = true, &block)
|
415
|
+
on_schema(TEMPORAL_SCHEMA, nesting, &block)
|
416
|
+
end
|
417
|
+
|
418
|
+
def _on_history_schema(nesting = true, &block)
|
419
|
+
on_schema(HISTORY_SCHEMA, nesting, &block)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
end
|