chrono_model 0.3.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.
- 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
|