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 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