chrono_model 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ spec/config.yml
16
+ spec/debug.log
17
+ test/tmp
18
+ test/version_tmp
19
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in chrono_model.gemspec
4
+ gemspec
5
+
6
+ group :development do
7
+ gem 'ruby-debug19'
8
+ gem 'pry'
9
+ gem 'rspec'
10
+ end
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 &gt;= 1.9.2
48
+ * Active Record &gt;= 3.2
49
+ * PostgreSQL &gt;= 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,7 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ # RSpec
5
+ require 'rspec/core/rake_task'
6
+ RSpec::Core::RakeTask.new
7
+ task :default => :spec
@@ -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
@@ -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