chrono_model 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.travis.yml +19 -14
- data/README.md +49 -25
- data/lib/chrono_model.rb +37 -3
- data/lib/chrono_model/adapter.rb +91 -874
- data/lib/chrono_model/adapter/ddl.rb +225 -0
- data/lib/chrono_model/adapter/indexes.rb +194 -0
- data/lib/chrono_model/adapter/migrations.rb +282 -0
- data/lib/chrono_model/adapter/tsrange.rb +57 -0
- data/lib/chrono_model/adapter/upgrade.rb +120 -0
- data/lib/chrono_model/conversions.rb +20 -0
- data/lib/chrono_model/json.rb +28 -0
- data/lib/chrono_model/patches.rb +8 -232
- data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
- data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
- data/lib/chrono_model/patches/association.rb +52 -0
- data/lib/chrono_model/patches/db_console.rb +11 -0
- data/lib/chrono_model/patches/join_node.rb +32 -0
- data/lib/chrono_model/patches/preloader.rb +68 -0
- data/lib/chrono_model/patches/relation.rb +58 -0
- data/lib/chrono_model/time_gate.rb +5 -5
- data/lib/chrono_model/time_machine.rb +47 -427
- data/lib/chrono_model/time_machine/history_model.rb +196 -0
- data/lib/chrono_model/time_machine/time_query.rb +86 -0
- data/lib/chrono_model/time_machine/timeline.rb +94 -0
- data/lib/chrono_model/utilities.rb +27 -0
- data/lib/chrono_model/version.rb +1 -1
- data/spec/aruba/dbconsole_spec.rb +25 -0
- data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
- data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
- data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
- data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
- data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
- data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
- data/spec/config.travis.yml +1 -0
- data/spec/config.yml.example +1 -0
- metadata +35 -14
- data/lib/chrono_model/utils.rb +0 -117
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 143b16e6e193bd56c88d1541f965150b07af191992aec7978dc94c84d7e53f87
|
4
|
+
data.tar.gz: 4aa06ed4110a6dbd9047f580b2972589d813f5108b422fe6ba0a80c021d56d03
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 57a62853b840b3edd78d1e0402c8d66458b01980956c87c880b84261490ed6f251a00b497c70f56646b4dd051869554118fc426d31b9bfda0af367e8977f3612
|
7
|
+
data.tar.gz: 854b001d29648c4b191eb7e985d720d72bd82cd43dc40df481cc63a83d684fe15922b8127ea40418ad89e5cc7cee30bb3e2551e2eeb6964bbb6477a97aa2f109
|
data/.travis.yml
CHANGED
@@ -1,3 +1,8 @@
|
|
1
|
+
sudo: false
|
2
|
+
|
3
|
+
language: ruby
|
4
|
+
cache: bundler
|
5
|
+
|
1
6
|
rvm:
|
2
7
|
- 2.3
|
3
8
|
- 2.4
|
@@ -8,27 +13,27 @@ gemfile:
|
|
8
13
|
- gemfiles/rails_5.1.gemfile
|
9
14
|
- gemfiles/rails_5.2.gemfile
|
10
15
|
|
11
|
-
#matrix:
|
12
|
-
# exclude:
|
13
|
-
# - rvm: 2.4
|
14
|
-
# gemfile: gemfiles/rails_4.2.gemfile
|
15
|
-
|
16
|
-
sudo: false
|
17
|
-
|
18
|
-
language: ruby
|
19
|
-
cache: bundler
|
20
|
-
|
21
16
|
addons:
|
22
|
-
postgresql: "9.6"
|
23
|
-
apt:
|
24
|
-
packages: postgresql-plpython-9.6
|
25
17
|
code_climate:
|
26
18
|
repo_token: dedfb7472ee410eec459bff3681d9a8fd8dd237e9bd7e8675a7c8eb7e253bba9
|
27
19
|
|
28
|
-
|
20
|
+
postgresql: "10"
|
21
|
+
apt:
|
22
|
+
packages:
|
23
|
+
- postgresql-10
|
24
|
+
- postgresql-client-10
|
25
|
+
|
26
|
+
before_install:
|
27
|
+
- sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf
|
28
|
+
- sudo /etc/init.d/postgresql restart
|
29
|
+
- sleep 2
|
29
30
|
- psql -c "CREATE DATABASE chronomodel;" -U postgres
|
30
31
|
- psql -c "CREATE DATABASE chronomodel_railsapp;" -U postgres
|
31
32
|
|
33
|
+
env:
|
34
|
+
global:
|
35
|
+
- PGPORT=5433
|
36
|
+
|
32
37
|
script:
|
33
38
|
- bundle exec rake TEST_CONFIG=./spec/config.travis.yml
|
34
39
|
|
data/README.md
CHANGED
@@ -1,15 +1,11 @@
|
|
1
1
|
# Temporal database system on PostgreSQL using [updatable views][pg-updatable-views], [table inheritance][pg-table-inheritance] and [INSTEAD OF triggers][pg-instead-of-triggers].
|
2
2
|
|
3
3
|
[![Build Status][build-status-badge]][build-status]
|
4
|
-
[![Dependency Status][deps-status-badge]][deps-status]
|
5
4
|
[![Code Climate][code-analysis-badge]][code-analysis]
|
6
5
|
[![Test Coverage][test-coverage-badge]][test-coverage]
|
7
6
|
[![Inlinedocs][docs-analysis-badge]][docs-analysis]
|
8
7
|
|
9
|
-
![{
|
10
|
-
|
11
|
-
> Chronos, the greek god of time.
|
12
|
-
> Courtesy of [REBELLE SOCIETY][rebelle-society]
|
8
|
+
![{A Delorean that we all love}][delorean-image]
|
13
9
|
|
14
10
|
ChronoModel implements what Oracle sells as "Flashback Queries", with standard
|
15
11
|
SQL on free PostgreSQL. Academically speaking, ChronoModel implements a
|
@@ -67,17 +63,16 @@ All timestamps are _forcibly_ stored in as UTC, bypassing the
|
|
67
63
|
|
68
64
|
* Ruby >= 2.3
|
69
65
|
* Active Record >= 5.0. See the [detailed supported versions matrix on travis](https://travis-ci.org/ifad/chronomodel)
|
70
|
-
* PostgreSQL >= 9.3
|
66
|
+
* PostgreSQL >= 9.4 (legacy support for 9.3)
|
71
67
|
* The `btree_gist` PostgreSQL extension
|
72
|
-
* The `plpython` PostgreSQL extension if you have *JSON* (*not* JSONB) columns
|
73
68
|
|
74
69
|
With Homebrew:
|
75
70
|
|
76
|
-
brew install
|
71
|
+
brew install postgres
|
77
72
|
|
78
|
-
With
|
73
|
+
With apt:
|
79
74
|
|
80
|
-
apt-get install postgresql-
|
75
|
+
apt-get install postgresql-11
|
81
76
|
|
82
77
|
## Installation
|
83
78
|
|
@@ -179,6 +174,21 @@ This is visible in `psql` if you issue a `\d+`. Example after a test run:
|
|
179
174
|
public | test_table | view | chronomodel | 0 bytes | {"temporal":true,"journal":["foo"],"chronomodel":"0.7.0.alpha"}
|
180
175
|
|
181
176
|
|
177
|
+
## Using Rails Counter Cache
|
178
|
+
|
179
|
+
**IMPORTANT**: Rails counter cache issues an UPDATE on the parent record
|
180
|
+
table, thus triggering new history entries creation. You are **strongly**
|
181
|
+
advised to NOT journal the counter cache columns, or race conditions will
|
182
|
+
occur (see https://github.com/ifad/chronomodel/issues/71).
|
183
|
+
|
184
|
+
In such cases, ensure to add `no_journal: %w( your_counter_cache_column_name )`
|
185
|
+
to your `create_table`. Example:
|
186
|
+
|
187
|
+
create_table 'sections', temporal: true, no_journal: %w( articles_count ) do |t|
|
188
|
+
t.string :name
|
189
|
+
t.integer :articles_count, default: 0
|
190
|
+
end
|
191
|
+
|
182
192
|
## Data querying
|
183
193
|
|
184
194
|
Include the `ChronoModel::TimeMachine` module in your model.
|
@@ -278,35 +288,51 @@ given that usually database objects have creation and dropping scripts.
|
|
278
288
|
|
279
289
|
## Running tests
|
280
290
|
|
281
|
-
You need a running PostgreSQL >= 9.
|
291
|
+
You need a running PostgreSQL >= 9.4 instance. Create `spec/config.yml` with the
|
282
292
|
connection authentication details (use `spec/config.yml.example` as template).
|
283
293
|
|
284
294
|
You need to connect as a database superuser, because specs need to create the
|
285
295
|
`btree_gist` extension.
|
286
296
|
|
287
|
-
|
288
|
-
|
297
|
+
To run the full test suite, use
|
298
|
+
|
299
|
+
rake
|
289
300
|
|
301
|
+
SQL queries are logged to `spec/debug.log`. If you want to see them in your
|
302
|
+
output, set the `VERBOSE=true` environment variable.
|
303
|
+
|
304
|
+
Some tests check the nominal execution of rake tasks within a test Rails app,
|
305
|
+
and those are quite time consuming. You can run the full ChronoModel tests
|
306
|
+
only against ActiveRecord by using
|
307
|
+
|
308
|
+
rspec spec/chrono_model
|
309
|
+
|
310
|
+
Ensure to run the full test suite before pushing.
|
290
311
|
|
291
312
|
## Usage with JSON (*not* JSONB) columns
|
292
313
|
|
293
|
-
|
314
|
+
**DEPRECATED**: Please migrate to JSONB. It has an equality operator built-in,
|
315
|
+
it's faster and stricter, and offers many more indexing abilities and better
|
316
|
+
performance than JSON. It is going to be desupported soon because PostgreSQL 10
|
317
|
+
does not support these anymore.
|
318
|
+
|
319
|
+
The [JSON][pg-json-type] does not provide an [equality operator][pg-json-func].
|
294
320
|
As both unnecessary update suppression and selective journaling require
|
295
321
|
comparing the OLD and NEW rows fields, this fails by default.
|
296
322
|
|
297
|
-
ChronoModel provides a naive JSON equality operator using
|
298
|
-
|
323
|
+
ChronoModel provides a naive and heavyweight JSON equality operator using
|
324
|
+
[pl/python][pg-json-opclass] and associated Postgres objects.
|
299
325
|
|
300
|
-
To
|
301
|
-
convenience method. If you don't use JSON don't bother doing this.
|
326
|
+
To set up you can use
|
302
327
|
|
303
|
-
|
304
|
-
|
305
|
-
|
328
|
+
```ruby
|
329
|
+
require 'chrono_model/json'
|
330
|
+
ChronoModel::Json.create
|
331
|
+
```
|
306
332
|
|
307
333
|
## Caveats
|
308
334
|
|
309
|
-
* Rails 4 support requires disabling tsrange parsing support, as it
|
335
|
+
* Rails 4+ support requires disabling tsrange parsing support, as it
|
310
336
|
[is broken][r4-tsrange-broken] and [incomplete][r4-tsrange-incomplete]
|
311
337
|
as of now, mainly due to a [design clash with ruby][pg-tsrange-and-ruby].
|
312
338
|
|
@@ -351,8 +377,6 @@ This software is Made in Italy :it: :smile:.
|
|
351
377
|
|
352
378
|
[build-status]: https://travis-ci.org/ifad/chronomodel
|
353
379
|
[build-status-badge]: https://travis-ci.org/ifad/chronomodel.svg
|
354
|
-
[deps-status]: https://gemnasium.com/ifad/chronomodel
|
355
|
-
[deps-status-badge]: https://gemnasium.com/ifad/chronomodel.svg
|
356
380
|
[code-analysis]: https://codeclimate.com/github/ifad/chronomodel
|
357
381
|
[code-analysis-badge]: https://codeclimate.com/github/ifad/chronomodel.svg
|
358
382
|
[docs-analysis]: http://inch-ci.org/github/ifad/chronomodel
|
@@ -360,7 +384,7 @@ This software is Made in Italy :it: :smile:.
|
|
360
384
|
[test-coverage]: https://codeclimate.com/github/ifad/chronomodel
|
361
385
|
[test-coverage-badge]: https://codeclimate.com/github/ifad/chronomodel/badges/coverage.svg
|
362
386
|
|
363
|
-
[
|
387
|
+
[delorean-image]: https://i.imgur.com/DD77F4s.jpg
|
364
388
|
[rebelle-society]: http://www.rebellesociety.com/2012/10/11/the-writers-way-week-two-facing-procrastination/chronos_oeuvre_grand1/
|
365
389
|
|
366
390
|
[wp-scd-2]: http://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2
|
data/lib/chrono_model.rb
CHANGED
@@ -1,14 +1,18 @@
|
|
1
|
-
require '
|
2
|
-
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
require 'chrono_model/conversions'
|
3
4
|
require 'chrono_model/patches'
|
5
|
+
require 'chrono_model/adapter'
|
4
6
|
require 'chrono_model/time_machine'
|
5
7
|
require 'chrono_model/time_gate'
|
6
|
-
require 'chrono_model/
|
8
|
+
require 'chrono_model/version'
|
7
9
|
|
8
10
|
module ChronoModel
|
9
11
|
class Error < ActiveRecord::ActiveRecordError #:nodoc:
|
10
12
|
end
|
11
13
|
|
14
|
+
# Performs structure upgrade.
|
15
|
+
#
|
12
16
|
def self.upgrade!
|
13
17
|
connection = ActiveRecord::Base.connection
|
14
18
|
|
@@ -18,20 +22,44 @@ module ChronoModel
|
|
18
22
|
|
19
23
|
connection.chrono_upgrade!
|
20
24
|
end
|
25
|
+
|
26
|
+
# Returns an Hash keyed by table name of ChronoModels.
|
27
|
+
# Computed upon inclusion of the +TimeMachine+ module.
|
28
|
+
#
|
29
|
+
def self.history_models
|
30
|
+
@_history_models||= {}
|
31
|
+
end
|
21
32
|
end
|
22
33
|
|
23
34
|
if defined?(Rails)
|
24
35
|
require 'chrono_model/railtie'
|
25
36
|
end
|
26
37
|
|
38
|
+
ActiveRecord::Base.instance_eval do
|
39
|
+
# Checks whether this Active Recoed model is backed by a temporal table
|
40
|
+
#
|
41
|
+
def chrono?
|
42
|
+
connection.is_chrono?(table_name)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Hooks into Association#scope to pass the As-Of time automatically
|
47
|
+
# to methods that load associated ChronoModel records.
|
48
|
+
#
|
27
49
|
ActiveRecord::Associations::Association.instance_eval do
|
28
50
|
prepend ChronoModel::Patches::Association
|
29
51
|
end
|
30
52
|
|
53
|
+
# Hooks into Relation#build_arel to use :joins on your ChronoModels
|
54
|
+
# and join data from associated records As-Of time.
|
55
|
+
#
|
31
56
|
ActiveRecord::Relation.instance_eval do
|
32
57
|
prepend ChronoModel::Patches::Relation
|
33
58
|
end
|
34
59
|
|
60
|
+
# Hooks in two points of the AR Preloader to preload As-Of time records of
|
61
|
+
# associated ChronoModels. is used by .includes, .preload and .eager_load.
|
62
|
+
#
|
35
63
|
ActiveRecord::Associations::Preloader.instance_eval do
|
36
64
|
prepend ChronoModel::Patches::Preloader
|
37
65
|
end
|
@@ -39,3 +67,9 @@ end
|
|
39
67
|
ActiveRecord::Associations::Preloader::Association.instance_eval do
|
40
68
|
prepend ChronoModel::Patches::Preloader::Association
|
41
69
|
end
|
70
|
+
|
71
|
+
if defined?(Rails::DBConsole)
|
72
|
+
Rails::DBConsole.instance_eval do
|
73
|
+
prepend ChronoModel::Patches::DBConsole
|
74
|
+
end
|
75
|
+
end
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -1,7 +1,10 @@
|
|
1
|
-
require 'active_record'
|
2
1
|
require 'active_record/connection_adapters/postgresql_adapter'
|
3
2
|
|
4
|
-
require '
|
3
|
+
require 'chrono_model/adapter/migrations'
|
4
|
+
require 'chrono_model/adapter/ddl'
|
5
|
+
require 'chrono_model/adapter/indexes'
|
6
|
+
require 'chrono_model/adapter/tsrange'
|
7
|
+
require 'chrono_model/adapter/upgrade'
|
5
8
|
|
6
9
|
module ChronoModel
|
7
10
|
|
@@ -10,15 +13,18 @@ module ChronoModel
|
|
10
13
|
# adapter for a clean override of its methods using super.
|
11
14
|
#
|
12
15
|
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
16
|
+
include ChronoModel::Adapter::Migrations
|
17
|
+
include ChronoModel::Adapter::DDL
|
18
|
+
include ChronoModel::Adapter::Indexes
|
19
|
+
include ChronoModel::Adapter::TSRange
|
20
|
+
include ChronoModel::Adapter::Upgrade
|
21
|
+
|
13
22
|
# The schema holding current data
|
14
23
|
TEMPORAL_SCHEMA = 'temporal'
|
15
24
|
|
16
25
|
# The schema holding historical data
|
17
26
|
HISTORY_SCHEMA = 'history'
|
18
27
|
|
19
|
-
# This is the data type used for the SCD2 validity
|
20
|
-
RANGE_TYPE = 'tsrange'
|
21
|
-
|
22
28
|
# Returns true whether the connection adapter supports our
|
23
29
|
# implementation of temporal tables. Currently, Chronomodel
|
24
30
|
# is supported starting with PostgreSQL 9.3.
|
@@ -27,267 +33,37 @@ module ChronoModel
|
|
27
33
|
postgresql_version >= 90300
|
28
34
|
end
|
29
35
|
|
30
|
-
|
31
|
-
|
32
|
-
#
|
33
|
-
def create_table(table_name, options = {})
|
34
|
-
# No temporal features requested, skip
|
35
|
-
return super unless options[:temporal]
|
36
|
-
|
37
|
-
if options[:id] == false
|
38
|
-
logger.warn "ChronoModel: Temporal Temporal tables require a primary key."
|
39
|
-
logger.warn "ChronoModel: Adding a `__chrono_id' primary key to #{table_name} definition."
|
40
|
-
|
41
|
-
options[:id] = '__chrono_id'
|
42
|
-
end
|
43
|
-
|
44
|
-
transaction do
|
45
|
-
_on_temporal_schema { super }
|
46
|
-
_on_history_schema { chrono_create_history_for(table_name) }
|
47
|
-
|
48
|
-
chrono_create_view_for(table_name, options)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
# If renaming a temporal table, rename the history and view as well.
|
53
|
-
#
|
54
|
-
def rename_table(name, new_name)
|
55
|
-
return super unless is_chrono?(name)
|
56
|
-
|
57
|
-
clear_cache!
|
58
|
-
|
59
|
-
transaction do
|
60
|
-
# Rename tables
|
61
|
-
#
|
62
|
-
[TEMPORAL_SCHEMA, HISTORY_SCHEMA].each do |schema|
|
63
|
-
on_schema(schema) do
|
64
|
-
seq = serial_sequence(name, primary_key(name))
|
65
|
-
new_seq = seq.sub(name.to_s, new_name.to_s).split('.').last
|
66
|
-
|
67
|
-
execute "ALTER SEQUENCE #{seq} RENAME TO #{new_seq}"
|
68
|
-
execute "ALTER TABLE #{name} RENAME TO #{new_name}"
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
|
73
|
-
# Rename indexes on history schema
|
74
|
-
#
|
75
|
-
_on_history_schema do
|
76
|
-
standard_index_names = %w(
|
77
|
-
inherit_pkey instance_history pkey
|
78
|
-
recorded_at timeline_consistency )
|
79
|
-
|
80
|
-
old_names = temporal_index_names(name, :validity) +
|
81
|
-
standard_index_names.map {|i| [name, i].join('_') }
|
82
|
-
|
83
|
-
new_names = temporal_index_names(new_name, :validity) +
|
84
|
-
standard_index_names.map {|i| [new_name, i].join('_') }
|
85
|
-
|
86
|
-
old_names.zip(new_names).each do |old, new|
|
87
|
-
execute "ALTER INDEX #{old} RENAME TO #{new}"
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
# Rename indexes on temporal schema
|
92
|
-
#
|
93
|
-
_on_temporal_schema do
|
94
|
-
temporal_indexes = indexes(new_name)
|
95
|
-
temporal_indexes.map(&:name).each do |old_idx_name|
|
96
|
-
if old_idx_name =~ /^index_#{name}_on_(?<columns>.+)/
|
97
|
-
new_idx_name = "index_#{new_name}_on_#{$~['columns']}"
|
98
|
-
execute "ALTER INDEX #{old_idx_name} RENAME TO #{new_idx_name}"
|
99
|
-
end
|
100
|
-
end
|
101
|
-
end
|
102
|
-
|
103
|
-
# Drop view
|
104
|
-
#
|
105
|
-
execute "DROP VIEW #{name}"
|
106
|
-
|
107
|
-
# Drop functions
|
108
|
-
#
|
109
|
-
chrono_drop_trigger_functions_for(name)
|
110
|
-
|
111
|
-
# Create view and functions
|
112
|
-
#
|
113
|
-
chrono_create_view_for(new_name)
|
114
|
-
end
|
115
|
-
end
|
116
|
-
|
117
|
-
# If changing a temporal table, redirect the change to the table in the
|
118
|
-
# temporal schema and recreate views.
|
119
|
-
#
|
120
|
-
# If the `:temporal` option is specified, enables or disables temporal
|
121
|
-
# features on the given table. Please note that you'll lose your history
|
122
|
-
# when demoting a temporal table to a plain one.
|
123
|
-
#
|
124
|
-
def change_table(table_name, options = {}, &block)
|
125
|
-
transaction do
|
126
|
-
|
127
|
-
# Add an empty proc to support calling change_table without a block.
|
128
|
-
#
|
129
|
-
block ||= proc { }
|
130
|
-
|
131
|
-
if options[:temporal] == true
|
132
|
-
if !is_chrono?(table_name)
|
133
|
-
# Add temporal features to this table
|
134
|
-
#
|
135
|
-
if !primary_key(table_name)
|
136
|
-
execute "ALTER TABLE #{table_name} ADD __chrono_id SERIAL PRIMARY KEY"
|
137
|
-
end
|
138
|
-
|
139
|
-
execute "ALTER TABLE #{table_name} SET SCHEMA #{TEMPORAL_SCHEMA}"
|
140
|
-
_on_history_schema { chrono_create_history_for(table_name) }
|
141
|
-
chrono_create_view_for(table_name, options)
|
142
|
-
copy_indexes_to_history_for(table_name)
|
143
|
-
|
144
|
-
# Optionally copy the plain table data, setting up history
|
145
|
-
# retroactively.
|
146
|
-
#
|
147
|
-
if options[:copy_data]
|
148
|
-
seq = _on_history_schema { serial_sequence(table_name, primary_key(table_name)) }
|
149
|
-
from = options[:validity] || '0001-01-01 00:00:00'
|
150
|
-
|
151
|
-
execute %[
|
152
|
-
INSERT INTO #{HISTORY_SCHEMA}.#{table_name}
|
153
|
-
SELECT *,
|
154
|
-
nextval('#{seq}') AS hid,
|
155
|
-
tsrange('#{from}', NULL) AS validity,
|
156
|
-
timezone('UTC', now()) AS recorded_at
|
157
|
-
FROM #{TEMPORAL_SCHEMA}.#{table_name}
|
158
|
-
]
|
159
|
-
end
|
160
|
-
end
|
161
|
-
|
162
|
-
chrono_alter(table_name, options) { super table_name, options, &block }
|
163
|
-
else
|
164
|
-
if options[:temporal] == false && is_chrono?(table_name)
|
165
|
-
# Remove temporal features from this table
|
166
|
-
#
|
167
|
-
execute "DROP VIEW #{table_name}"
|
168
|
-
|
169
|
-
chrono_drop_trigger_functions_for(table_name)
|
170
|
-
|
171
|
-
_on_history_schema { execute "DROP TABLE #{table_name}" }
|
172
|
-
|
173
|
-
default_schema = select_value 'SELECT current_schema()'
|
174
|
-
_on_temporal_schema do
|
175
|
-
if primary_key(table_name) == '__chrono_id'
|
176
|
-
execute "ALTER TABLE #{table_name} DROP __chrono_id"
|
177
|
-
end
|
178
|
-
|
179
|
-
execute "ALTER TABLE #{table_name} SET SCHEMA #{default_schema}"
|
180
|
-
end
|
181
|
-
end
|
36
|
+
def chrono_setup!
|
37
|
+
chrono_ensure_schemas
|
182
38
|
|
183
|
-
|
184
|
-
end
|
185
|
-
end
|
39
|
+
chrono_upgrade_warning
|
186
40
|
end
|
187
41
|
|
188
|
-
#
|
189
|
-
#
|
42
|
+
# Runs primary_key, indexes and default_sequence_name in the
|
43
|
+
# temporal schema, as the table there defined is the source for
|
44
|
+
# this information.
|
190
45
|
#
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
_on_temporal_schema { execute "DROP TABLE #{table_name} CASCADE" }
|
195
|
-
|
196
|
-
chrono_drop_trigger_functions_for(table_name)
|
197
|
-
end
|
198
|
-
|
199
|
-
# If adding an index to a temporal table, add it to the one in the
|
200
|
-
# temporal schema and to the history one. If the `:unique` option is
|
201
|
-
# present, it is removed from the index created in the history table.
|
46
|
+
# Moreover, the PostgreSQLAdapter +indexes+ method uses
|
47
|
+
# current_schema(), thus this is the only (and cleanest) way to
|
48
|
+
# make injection work.
|
202
49
|
#
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
transaction do
|
207
|
-
_on_temporal_schema { super }
|
208
|
-
|
209
|
-
# Uniqueness constraints do not make sense in the history table
|
210
|
-
options = options.dup.tap {|o| o.delete(:unique)} if options[:unique].present?
|
211
|
-
|
212
|
-
_on_history_schema { super table_name, column_name, options }
|
213
|
-
end
|
214
|
-
end
|
215
|
-
|
216
|
-
# If removing an index from a temporal table, remove it both from the
|
217
|
-
# temporal and the history schemas.
|
50
|
+
# Schema nesting is disabled on these calls, make sure to fetch
|
51
|
+
# metadata from the first caller's selected schema and not from
|
52
|
+
# the current one.
|
218
53
|
#
|
219
|
-
|
220
|
-
return super unless is_chrono?(table_name)
|
221
|
-
|
222
|
-
transaction do
|
223
|
-
_on_temporal_schema { super }
|
224
|
-
_on_history_schema { super }
|
225
|
-
end
|
226
|
-
end
|
227
|
-
|
228
|
-
# If adding a column to a temporal table, creates it in the table in
|
229
|
-
# the temporal schema and updates the triggers.
|
54
|
+
# NOTE: These methods are dynamically defined, see the source.
|
230
55
|
#
|
231
|
-
def
|
232
|
-
return super unless is_chrono?(table_name)
|
233
|
-
|
234
|
-
transaction do
|
235
|
-
# Add the column to the temporal table
|
236
|
-
_on_temporal_schema { super }
|
237
|
-
|
238
|
-
# Update the triggers
|
239
|
-
chrono_create_view_for(table_name)
|
240
|
-
end
|
56
|
+
def primary_key(table_name)
|
241
57
|
end
|
242
58
|
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
# Rename the column in the temporal table and in the view
|
250
|
-
transaction do
|
251
|
-
_on_temporal_schema { super }
|
252
|
-
super
|
253
|
-
|
254
|
-
# Update the triggers
|
255
|
-
chrono_create_view_for(table_name)
|
59
|
+
[:primary_key, :indexes, :default_sequence_name].each do |method|
|
60
|
+
define_method(method) do |*args|
|
61
|
+
table_name = args.first
|
62
|
+
return super(*args) unless is_chrono?(table_name)
|
63
|
+
on_schema(TEMPORAL_SCHEMA, recurse: :ignore) { super(*args) }
|
256
64
|
end
|
257
65
|
end
|
258
66
|
|
259
|
-
# If removing a column from a temporal table, we are forced to drop the
|
260
|
-
# view, then change the column from the table in the temporal schema and
|
261
|
-
# eventually recreate the triggers.
|
262
|
-
#
|
263
|
-
def change_column(table_name, *)
|
264
|
-
return super unless is_chrono?(table_name)
|
265
|
-
chrono_alter(table_name) { super }
|
266
|
-
end
|
267
|
-
|
268
|
-
# Change the default on the temporal schema table.
|
269
|
-
#
|
270
|
-
def change_column_default(table_name, *)
|
271
|
-
return super unless is_chrono?(table_name)
|
272
|
-
_on_temporal_schema { super }
|
273
|
-
end
|
274
|
-
|
275
|
-
# Change the null constraint on the temporal schema table.
|
276
|
-
#
|
277
|
-
def change_column_null(table_name, *)
|
278
|
-
return super unless is_chrono?(table_name)
|
279
|
-
_on_temporal_schema { super }
|
280
|
-
end
|
281
|
-
|
282
|
-
# If removing a column from a temporal table, we are forced to drop the
|
283
|
-
# view, then drop the column from the table in the temporal schema and
|
284
|
-
# eventually recreate the triggers.
|
285
|
-
#
|
286
|
-
def remove_column(table_name, *)
|
287
|
-
return super unless is_chrono?(table_name)
|
288
|
-
chrono_alter(table_name) { super }
|
289
|
-
end
|
290
|
-
|
291
67
|
# Runs column_definitions in the temporal schema, as the table there
|
292
68
|
# defined is the source for this information.
|
293
69
|
#
|
@@ -295,256 +71,104 @@ module ChronoModel
|
|
295
71
|
# may reference types defined in other schemas, which result in their
|
296
72
|
# names becoming schema qualified, which will cause type resolutions to fail.
|
297
73
|
#
|
298
|
-
|
299
|
-
return super(table_name) unless is_chrono?(table_name)
|
300
|
-
on_schema(TEMPORAL_SCHEMA + ',' + self.schema_search_path, false) { super(table_name) }
|
301
|
-
end
|
302
|
-
|
303
|
-
# Runs primary_key, indexes and default_sequence_name in the temporal schema,
|
304
|
-
# as the table there defined is the source for this information.
|
305
|
-
#
|
306
|
-
# Moreover, the PostgreSQLAdapter +indexes+ method uses current_schema(),
|
307
|
-
# thus this is the only (and cleanest) way to make injection work.
|
308
|
-
#
|
309
|
-
# Schema nesting is disabled on these calls, make sure to fetch metadata
|
310
|
-
# from the first caller's selected schema and not from the current one.
|
74
|
+
# NOTE: This method is dynamically defined, see the source.
|
311
75
|
#
|
312
|
-
|
313
|
-
define_method(method) do |*args|
|
314
|
-
table_name = args.first
|
315
|
-
return super(*args) unless is_chrono?(table_name)
|
316
|
-
_on_temporal_schema(false) { super(*args) }
|
317
|
-
end
|
318
|
-
end
|
319
|
-
|
320
|
-
# Create spatial indexes for timestamp search.
|
321
|
-
#
|
322
|
-
# This index is used by +TimeMachine.at+, `.current` and `.past` to
|
323
|
-
# build the temporal WHERE clauses that fetch the state of records at
|
324
|
-
# a single point in time.
|
325
|
-
#
|
326
|
-
# Parameters:
|
327
|
-
#
|
328
|
-
# `table`: the table where to create indexes on
|
329
|
-
# `range`: the tsrange field
|
330
|
-
#
|
331
|
-
# Options:
|
332
|
-
#
|
333
|
-
# `:name`: the index name prefix, defaults to
|
334
|
-
# index_{table}_temporal_on_{range / lower_range / upper_range}
|
335
|
-
#
|
336
|
-
def add_temporal_indexes(table, range, options = {})
|
337
|
-
range_idx, lower_idx, upper_idx =
|
338
|
-
temporal_index_names(table, range, options)
|
339
|
-
|
340
|
-
chrono_alter_index(table, options) do
|
341
|
-
execute <<-SQL
|
342
|
-
CREATE INDEX #{range_idx} ON #{table} USING gist ( #{range} )
|
343
|
-
SQL
|
344
|
-
|
345
|
-
# Indexes used for precise history filtering, sorting and, in history
|
346
|
-
# tables, by UPDATE / DELETE triggers.
|
347
|
-
#
|
348
|
-
execute "CREATE INDEX #{lower_idx} ON #{table} ( lower(#{range}) )"
|
349
|
-
execute "CREATE INDEX #{upper_idx} ON #{table} ( upper(#{range}) )"
|
350
|
-
end
|
76
|
+
def column_definitions
|
351
77
|
end
|
352
78
|
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
chrono_alter_index(table, options) do
|
357
|
-
indexes.each {|idx| execute "DROP INDEX #{idx}" }
|
358
|
-
end
|
359
|
-
end
|
360
|
-
|
361
|
-
def temporal_index_names(table, range, options = {})
|
362
|
-
prefix = options[:name].presence || "index_#{table}_temporal"
|
363
|
-
|
364
|
-
# When creating computed indexes (e.g. ends_on::timestamp + time
|
365
|
-
# '23:59:59'), remove everything following the field name.
|
366
|
-
range = range.to_s.sub(/\W.*/, '')
|
367
|
-
|
368
|
-
[range, "lower_#{range}", "upper_#{range}"].map do |suffix|
|
369
|
-
[prefix, 'on', suffix].join('_')
|
370
|
-
end
|
79
|
+
define_method(:column_definitions) do |table_name|
|
80
|
+
return super(table_name) unless is_chrono?(table_name)
|
81
|
+
on_schema(TEMPORAL_SCHEMA + ',' + self.schema_search_path, recurse: :ignore) { super(table_name) }
|
371
82
|
end
|
372
83
|
|
373
|
-
|
374
|
-
# Adds an EXCLUDE constraint to the given table, to assure that
|
375
|
-
# no more than one record can occupy a definite segment on a
|
376
|
-
# timeline.
|
84
|
+
# Evaluates the given block in the temporal schema.
|
377
85
|
#
|
378
|
-
def
|
379
|
-
|
380
|
-
id = options[:id] || primary_key(table)
|
381
|
-
|
382
|
-
chrono_alter_constraint(table, options) do
|
383
|
-
execute <<-SQL
|
384
|
-
ALTER TABLE #{table} ADD CONSTRAINT #{name}
|
385
|
-
EXCLUDE USING gist ( #{id} WITH =, #{range} WITH && )
|
386
|
-
SQL
|
387
|
-
end
|
388
|
-
end
|
389
|
-
|
390
|
-
def remove_timeline_consistency_constraint(table, options = {})
|
391
|
-
name = timeline_consistency_constraint_name(options[:prefix] || table)
|
392
|
-
|
393
|
-
chrono_alter_constraint(table, options) do
|
394
|
-
execute <<-SQL
|
395
|
-
ALTER TABLE #{table} DROP CONSTRAINT #{name}
|
396
|
-
SQL
|
397
|
-
end
|
86
|
+
def on_temporal_schema(&block)
|
87
|
+
on_schema(TEMPORAL_SCHEMA, &block)
|
398
88
|
end
|
399
89
|
|
400
|
-
|
401
|
-
|
90
|
+
# Evaluates the given block in the history schema.
|
91
|
+
#
|
92
|
+
def on_history_schema(&block)
|
93
|
+
on_schema(HISTORY_SCHEMA, &block)
|
402
94
|
end
|
403
95
|
|
404
|
-
|
405
96
|
# Evaluates the given block in the given +schema+ search path.
|
406
97
|
#
|
407
|
-
#
|
408
|
-
#
|
98
|
+
# Recursion works by saving the old_path the function closure
|
99
|
+
# at each recursive call.
|
409
100
|
#
|
410
|
-
|
411
|
-
|
101
|
+
# See specs for examples and behaviour.
|
102
|
+
#
|
103
|
+
def on_schema(schema, recurse: :follow)
|
104
|
+
old_path = self.schema_search_path
|
412
105
|
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
106
|
+
count_recursions do
|
107
|
+
if recurse == :follow or Thread.current['recursions'] == 1
|
108
|
+
self.schema_search_path = schema
|
109
|
+
end
|
417
110
|
|
418
|
-
|
111
|
+
yield
|
112
|
+
end
|
419
113
|
|
420
114
|
ensure
|
421
|
-
|
115
|
+
# If the transaction is aborted, any execute() call will raise
|
116
|
+
# "transaction is aborted errors" - thus calling the Adapter's
|
117
|
+
# setter won't update the memoized variable.
|
118
|
+
#
|
119
|
+
# Here we reset it to +nil+ to refresh it on the next call, as
|
120
|
+
# there is no way to know which path will be restored when the
|
121
|
+
# transaction ends.
|
122
|
+
#
|
123
|
+
transaction_aborted =
|
124
|
+
@connection.transaction_status == PG::Connection::PQTRANS_INERROR
|
422
125
|
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
# Here we reset it to +nil+ to refresh it on the next call, as
|
428
|
-
# there is no way to know which path will be restored when the
|
429
|
-
# transaction ends.
|
430
|
-
#
|
431
|
-
if @connection.transaction_status == PG::Connection::PQTRANS_INERROR
|
432
|
-
@schema_search_path = nil
|
433
|
-
else
|
434
|
-
self.schema_search_path = old_path
|
435
|
-
end
|
126
|
+
if transaction_aborted && Thread.current['recursions'] == 1
|
127
|
+
@schema_search_path = nil
|
128
|
+
else
|
129
|
+
self.schema_search_path = old_path
|
436
130
|
end
|
437
|
-
@_on_schema_nesting -= 1
|
438
131
|
end
|
439
132
|
|
440
133
|
# Returns true if the given name references a temporal table.
|
441
134
|
#
|
442
135
|
def is_chrono?(table)
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
rescue ActiveRecord::StatementInvalid => e
|
447
|
-
# means that we could not change the search path to check for
|
448
|
-
# table existence
|
449
|
-
if is_exception_class?(e, PG::InvalidSchemaName, PG::InvalidParameterValue)
|
450
|
-
return false
|
451
|
-
else
|
452
|
-
raise e
|
453
|
-
end
|
136
|
+
on_temporal_schema { data_source_exists?(table) } &&
|
137
|
+
on_history_schema { data_source_exists?(table) }
|
454
138
|
end
|
455
139
|
|
456
|
-
|
457
|
-
|
458
|
-
klasses.any? { |k| e.is_a?(k) }
|
459
|
-
else
|
460
|
-
klasses.any? { |k| e.message =~ /#{k.name}/ }
|
461
|
-
end
|
462
|
-
end
|
463
|
-
|
464
|
-
def chrono_setup!
|
465
|
-
chrono_ensure_schemas
|
466
|
-
|
467
|
-
chrono_upgrade_warning
|
468
|
-
end
|
469
|
-
|
470
|
-
def chrono_upgrade!
|
471
|
-
chrono_ensure_schemas
|
472
|
-
|
473
|
-
chrono_upgrade_structure!
|
474
|
-
end
|
475
|
-
|
476
|
-
# HACK: Redefine tsrange parsing support, as it is broken currently.
|
477
|
-
#
|
478
|
-
# This self-made API is here because currently AR4 does not support
|
479
|
-
# open-ended ranges. The reasons are poor support in Ruby:
|
480
|
-
#
|
481
|
-
# https://bugs.ruby-lang.org/issues/6864
|
482
|
-
#
|
483
|
-
# and an instable interface in Active Record:
|
140
|
+
# Reads the Gem metadata from the COMMENT set on the given PostgreSQL
|
141
|
+
# view name.
|
484
142
|
#
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
#
|
490
|
-
class TSRange < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
|
491
|
-
OID = 3908
|
492
|
-
|
493
|
-
def cast_value(value)
|
494
|
-
return if value == 'empty'
|
495
|
-
return value if value.is_a?(::Array)
|
496
|
-
|
497
|
-
extracted = extract_bounds(value)
|
498
|
-
|
499
|
-
from = Conversions.string_to_utc_time extracted[:from]
|
500
|
-
to = Conversions.string_to_utc_time extracted[:to ]
|
143
|
+
def chrono_metadata_for(view_name)
|
144
|
+
comment = select_value(
|
145
|
+
"SELECT obj_description(#{quote(view_name)}::regclass)",
|
146
|
+
"ChronoModel metadata for #{view_name}") if data_source_exists?(view_name)
|
501
147
|
|
502
|
-
|
503
|
-
end
|
504
|
-
|
505
|
-
def extract_bounds(value)
|
506
|
-
from, to = value[1..-2].split(',')
|
507
|
-
{
|
508
|
-
from: (value[1] == ',' || from == '-infinity') ? nil : from[1..-2],
|
509
|
-
to: (value[-2] == ',' || to == 'infinity') ? nil : to[1..-2],
|
510
|
-
}
|
511
|
-
end
|
148
|
+
MultiJson.load(comment || '{}').with_indifferent_access
|
512
149
|
end
|
513
150
|
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
151
|
+
# Writes Gem metadata on the COMMENT field in the given VIEW name.
|
152
|
+
#
|
153
|
+
def chrono_metadata_set(view_name, metadata)
|
154
|
+
comment = MultiJson.dump(metadata)
|
518
155
|
|
519
|
-
|
520
|
-
end
|
156
|
+
execute %[ COMMENT ON VIEW #{view_name} IS #{quote(comment)} ]
|
521
157
|
end
|
522
158
|
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
# Ref: GitHub pull #21.
|
530
|
-
#
|
531
|
-
def copy_indexes_to_history_for(table_name)
|
532
|
-
history_indexes = _on_history_schema { indexes(table_name) }.map(&:name)
|
533
|
-
temporal_indexes = _on_temporal_schema { indexes(table_name) }
|
159
|
+
private
|
160
|
+
# Counts the number of recursions in a thread local variable
|
161
|
+
#
|
162
|
+
def count_recursions # yield
|
163
|
+
Thread.current['recursions'] ||= 0
|
164
|
+
Thread.current['recursions'] += 1
|
534
165
|
|
535
|
-
|
536
|
-
next if history_indexes.include?(index.name)
|
166
|
+
yield
|
537
167
|
|
538
|
-
|
539
|
-
|
540
|
-
CREATE INDEX #{index.name} ON #{table_name}
|
541
|
-
USING #{index.using} ( #{index.columns.join(', ')} )
|
542
|
-
], 'Copy index from temporal to history'
|
543
|
-
end
|
168
|
+
ensure
|
169
|
+
Thread.current['recursions'] -= 1
|
544
170
|
end
|
545
|
-
end
|
546
171
|
|
547
|
-
private
|
548
172
|
# Create the temporal and history schemas, unless they already exist
|
549
173
|
#
|
550
174
|
def chrono_ensure_schemas
|
@@ -552,413 +176,6 @@ module ChronoModel
|
|
552
176
|
execute "CREATE SCHEMA #{schema}" unless schema_exists?(schema)
|
553
177
|
end
|
554
178
|
end
|
555
|
-
|
556
|
-
# Locate tables needing a structure upgrade
|
557
|
-
#
|
558
|
-
def chrono_tables_needing_upgrade
|
559
|
-
tables = { }
|
560
|
-
|
561
|
-
_on_temporal_schema { self.tables }.each do |table_name|
|
562
|
-
next unless is_chrono?(table_name)
|
563
|
-
metadata = chrono_metadata_for(table_name)
|
564
|
-
version = metadata['chronomodel']
|
565
|
-
|
566
|
-
if version.blank?
|
567
|
-
tables[table_name] = { version: nil, priority: 'CRITICAL' }
|
568
|
-
elsif version != VERSION
|
569
|
-
tables[table_name] = { version: version, priority: 'LOW' }
|
570
|
-
end
|
571
|
-
end
|
572
|
-
|
573
|
-
return tables
|
574
|
-
end
|
575
|
-
|
576
|
-
# Emit a warning about tables needing an upgrade
|
577
|
-
#
|
578
|
-
def chrono_upgrade_warning
|
579
|
-
upgrade = chrono_tables_needing_upgrade.map do |table, desc|
|
580
|
-
"#{table} - priority: #{desc[:priority]}"
|
581
|
-
end.join('; ')
|
582
|
-
|
583
|
-
return if upgrade.empty?
|
584
|
-
|
585
|
-
logger.warn "ChronoModel: There are tables needing a structure upgrade, and ChronoModel structures need to be recreated."
|
586
|
-
logger.warn "ChronoModel: Please run ChronoModel.upgrade! to attempt the upgrade. If you have dependant database objects"
|
587
|
-
logger.warn "ChronoModel: the upgrade will fail and you have to drop the dependent objects, run .upgrade! and create them"
|
588
|
-
logger.warn "ChronoModel: again. Sorry. Some features or the whole library may not work correctly until upgrade is complete."
|
589
|
-
logger.warn "ChronoModel: Tables pending upgrade: #{upgrade}"
|
590
|
-
end
|
591
|
-
|
592
|
-
# Upgrades existing structure for each table, if required.
|
593
|
-
#
|
594
|
-
def chrono_upgrade_structure!
|
595
|
-
transaction do
|
596
|
-
|
597
|
-
chrono_tables_needing_upgrade.each do |table_name, desc|
|
598
|
-
|
599
|
-
if desc[:version].blank?
|
600
|
-
logger.info "ChronoModel: Upgrading legacy table #{table_name} to #{VERSION}"
|
601
|
-
upgrade_from_legacy(table_name)
|
602
|
-
logger.info "ChronoModel: legacy #{table_name} upgrade complete"
|
603
|
-
else
|
604
|
-
logger.info "ChronoModel: upgrading #{table_name} from #{desc[:version]} to #{VERSION}"
|
605
|
-
chrono_create_view_for(table_name)
|
606
|
-
logger.info "ChronoModel: #{table_name} upgrade complete"
|
607
|
-
end
|
608
|
-
|
609
|
-
end
|
610
|
-
end
|
611
|
-
rescue => e
|
612
|
-
message = "ChronoModel structure upgrade failed: #{e.message}. Please drop dependent objects first and then run ChronoModel.upgrade! again."
|
613
|
-
|
614
|
-
# Quite important, output it also to stderr.
|
615
|
-
#
|
616
|
-
logger.error message
|
617
|
-
$stderr.puts message
|
618
|
-
end
|
619
|
-
|
620
|
-
def upgrade_from_legacy(table_name)
|
621
|
-
# roses are red
|
622
|
-
# violets are blue
|
623
|
-
# and this is the most boring piece of code ever
|
624
|
-
history_table = "#{HISTORY_SCHEMA}.#{table_name}"
|
625
|
-
p_pkey = primary_key(table_name)
|
626
|
-
|
627
|
-
execute "ALTER TABLE #{history_table} ADD COLUMN validity tsrange;"
|
628
|
-
execute """
|
629
|
-
UPDATE #{history_table} SET validity = tsrange(valid_from,
|
630
|
-
CASE WHEN extract(year from valid_to) = 9999 THEN NULL
|
631
|
-
ELSE valid_to
|
632
|
-
END
|
633
|
-
);
|
634
|
-
"""
|
635
|
-
|
636
|
-
execute "DROP INDEX #{history_table}_temporal_on_valid_from;"
|
637
|
-
execute "DROP INDEX #{history_table}_temporal_on_valid_from_and_valid_to;"
|
638
|
-
execute "DROP INDEX #{history_table}_temporal_on_valid_to;"
|
639
|
-
execute "DROP INDEX #{history_table}_inherit_pkey"
|
640
|
-
execute "DROP INDEX #{history_table}_recorded_at"
|
641
|
-
execute "DROP INDEX #{history_table}_instance_history"
|
642
|
-
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_valid_from_before_valid_to;"
|
643
|
-
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_timeline_consistency;"
|
644
|
-
execute "DROP RULE #{table_name}_upd_first ON #{table_name};"
|
645
|
-
execute "DROP RULE #{table_name}_upd_next ON #{table_name};"
|
646
|
-
execute "DROP RULE #{table_name}_del ON #{table_name};"
|
647
|
-
execute "DROP RULE #{table_name}_ins ON #{table_name};"
|
648
|
-
execute "DROP TRIGGER history_ins ON #{TEMPORAL_SCHEMA}.#{table_name};"
|
649
|
-
execute "DROP FUNCTION #{TEMPORAL_SCHEMA}.#{table_name}_ins();"
|
650
|
-
execute "ALTER TABLE #{history_table} DROP COLUMN valid_from;"
|
651
|
-
execute "ALTER TABLE #{history_table} DROP COLUMN valid_to;"
|
652
|
-
|
653
|
-
execute "CREATE EXTENSION IF NOT EXISTS btree_gist;"
|
654
|
-
|
655
|
-
chrono_create_view_for(table_name)
|
656
|
-
_on_history_schema { add_history_validity_constraint(table_name, p_pkey) }
|
657
|
-
_on_history_schema { chrono_create_history_indexes_for(table_name, p_pkey) }
|
658
|
-
end
|
659
|
-
|
660
|
-
def chrono_metadata_for(table)
|
661
|
-
comment = select_value(
|
662
|
-
"SELECT obj_description(#{quote(table)}::regclass)",
|
663
|
-
"ChronoModel metadata for #{table}") if chrono_data_source_exists?(table)
|
664
|
-
|
665
|
-
MultiJson.load(comment || '{}').with_indifferent_access
|
666
|
-
end
|
667
|
-
|
668
|
-
def chrono_metadata_set(table, metadata)
|
669
|
-
comment = MultiJson.dump(metadata)
|
670
|
-
|
671
|
-
execute %[
|
672
|
-
COMMENT ON VIEW #{table} IS #{quote(comment)}
|
673
|
-
]
|
674
|
-
end
|
675
|
-
|
676
|
-
def add_history_validity_constraint(table, pkey)
|
677
|
-
add_timeline_consistency_constraint(table, :validity, :id => pkey, :on_current_schema => true)
|
678
|
-
end
|
679
|
-
|
680
|
-
def remove_history_validity_constraint(table, options = {})
|
681
|
-
remove_timeline_consistency_constraint(table, options.merge(:on_current_schema => true))
|
682
|
-
end
|
683
|
-
|
684
|
-
# Create the history table in the history schema
|
685
|
-
def chrono_create_history_for(table)
|
686
|
-
parent = "#{TEMPORAL_SCHEMA}.#{table}"
|
687
|
-
p_pkey = primary_key(parent)
|
688
|
-
|
689
|
-
execute <<-SQL
|
690
|
-
CREATE TABLE #{table} (
|
691
|
-
hid SERIAL PRIMARY KEY,
|
692
|
-
validity #{RANGE_TYPE} NOT NULL,
|
693
|
-
recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now())
|
694
|
-
) INHERITS ( #{parent} )
|
695
|
-
SQL
|
696
|
-
|
697
|
-
add_history_validity_constraint(table, p_pkey)
|
698
|
-
|
699
|
-
chrono_create_history_indexes_for(table, p_pkey)
|
700
|
-
end
|
701
|
-
|
702
|
-
def chrono_create_history_indexes_for(table, p_pkey = nil)
|
703
|
-
# Duplicate because of Migrate.upgrade_indexes_for
|
704
|
-
# TODO remove me.
|
705
|
-
p_pkey ||= primary_key("#{TEMPORAL_SCHEMA}.#{table}")
|
706
|
-
|
707
|
-
add_temporal_indexes table, :validity, :on_current_schema => true
|
708
|
-
|
709
|
-
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
|
710
|
-
execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
|
711
|
-
execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
|
712
|
-
end
|
713
|
-
|
714
|
-
# Create the public view and its INSTEAD OF triggers
|
715
|
-
#
|
716
|
-
def chrono_create_view_for(table, options = nil)
|
717
|
-
pk = primary_key(table)
|
718
|
-
current = [TEMPORAL_SCHEMA, table].join('.')
|
719
|
-
history = [HISTORY_SCHEMA, table].join('.')
|
720
|
-
seq = serial_sequence(current, pk)
|
721
|
-
|
722
|
-
options ||= chrono_metadata_for(table)
|
723
|
-
|
724
|
-
# SELECT - return only current data
|
725
|
-
#
|
726
|
-
execute "DROP VIEW #{table}" if chrono_data_source_exists? table
|
727
|
-
execute "CREATE VIEW #{table} AS SELECT * FROM ONLY #{current}"
|
728
|
-
|
729
|
-
# Set default values on the view (closes #12)
|
730
|
-
#
|
731
|
-
chrono_metadata_set(table, options.merge(:chronomodel => VERSION))
|
732
|
-
|
733
|
-
columns(table).each do |column|
|
734
|
-
default = if column.default.nil?
|
735
|
-
column.default_function
|
736
|
-
else
|
737
|
-
if ActiveRecord::VERSION::MAJOR == 4
|
738
|
-
quote(column.default, column)
|
739
|
-
else # Rails 5 and beyond
|
740
|
-
quote(column.default)
|
741
|
-
end
|
742
|
-
end
|
743
|
-
|
744
|
-
next if column.name == pk || default.nil?
|
745
|
-
|
746
|
-
execute "ALTER VIEW #{table} ALTER COLUMN #{quote_column_name(column.name)} SET DEFAULT #{default}"
|
747
|
-
end
|
748
|
-
|
749
|
-
columns = columns(table).map {|c| quote_column_name(c.name)}
|
750
|
-
columns.delete(quote_column_name(pk))
|
751
|
-
|
752
|
-
fields, values = columns.join(', '), columns.map {|c| "NEW.#{c}"}.join(', ')
|
753
|
-
|
754
|
-
# Columns to be journaled. By default everything except updated_at (GH #7)
|
755
|
-
#
|
756
|
-
journal = if options[:journal]
|
757
|
-
options[:journal].map {|col| quote_column_name(col)}
|
758
|
-
|
759
|
-
elsif options[:no_journal]
|
760
|
-
columns - options[:no_journal].map {|col| quote_column_name(col)}
|
761
|
-
|
762
|
-
elsif options[:full_journal]
|
763
|
-
columns
|
764
|
-
|
765
|
-
else
|
766
|
-
columns - [ quote_column_name('updated_at') ]
|
767
|
-
end
|
768
|
-
|
769
|
-
journal &= columns
|
770
|
-
|
771
|
-
# INSERT - insert data both in the temporal table and in the history one.
|
772
|
-
#
|
773
|
-
# The serial sequence is invoked manually only if the PK is NULL, to
|
774
|
-
# allow setting the PK to a specific value (think migration scenario).
|
775
|
-
#
|
776
|
-
execute <<-SQL
|
777
|
-
CREATE OR REPLACE FUNCTION chronomodel_#{table}_insert() RETURNS TRIGGER AS $$
|
778
|
-
BEGIN
|
779
|
-
IF NEW.#{pk} IS NULL THEN
|
780
|
-
NEW.#{pk} := nextval('#{seq}');
|
781
|
-
END IF;
|
782
|
-
|
783
|
-
INSERT INTO #{current} ( #{pk}, #{fields} )
|
784
|
-
VALUES ( NEW.#{pk}, #{values} );
|
785
|
-
|
786
|
-
INSERT INTO #{history} ( #{pk}, #{fields}, validity )
|
787
|
-
VALUES ( NEW.#{pk}, #{values}, tsrange(timezone('UTC', now()), NULL) );
|
788
|
-
|
789
|
-
RETURN NEW;
|
790
|
-
END;
|
791
|
-
$$ LANGUAGE plpgsql;
|
792
|
-
|
793
|
-
DROP TRIGGER IF EXISTS chronomodel_insert ON #{table};
|
794
|
-
|
795
|
-
CREATE TRIGGER chronomodel_insert INSTEAD OF INSERT ON #{table}
|
796
|
-
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_insert();
|
797
|
-
SQL
|
798
|
-
|
799
|
-
# UPDATE - set the last history entry validity to now, save the current data
|
800
|
-
# in a new history entry and update the temporal table with the new data.
|
801
|
-
#
|
802
|
-
# If there are no changes, this trigger suppresses redundant updates.
|
803
|
-
#
|
804
|
-
# If a row in the history with the current ID and current timestamp already
|
805
|
-
# exists, update it with new data. This logic makes possible to "squash"
|
806
|
-
# together changes made in a transaction in a single history row.
|
807
|
-
#
|
808
|
-
# If you want to disable this behaviour, set the CHRONOMODEL_NO_SQUASH
|
809
|
-
# environment variable. This is useful when running scenarios inside
|
810
|
-
# cucumber, in which everything runs in the same transaction.
|
811
|
-
#
|
812
|
-
execute <<-SQL
|
813
|
-
CREATE OR REPLACE FUNCTION chronomodel_#{table}_update() RETURNS TRIGGER AS $$
|
814
|
-
DECLARE _now timestamp;
|
815
|
-
DECLARE _hid integer;
|
816
|
-
DECLARE _old record;
|
817
|
-
DECLARE _new record;
|
818
|
-
BEGIN
|
819
|
-
IF OLD IS NOT DISTINCT FROM NEW THEN
|
820
|
-
RETURN NULL;
|
821
|
-
END IF;
|
822
|
-
|
823
|
-
_old := row(#{journal.map {|c| "OLD.#{c}" }.join(', ')});
|
824
|
-
_new := row(#{journal.map {|c| "NEW.#{c}" }.join(', ')});
|
825
|
-
|
826
|
-
IF _old IS NOT DISTINCT FROM _new THEN
|
827
|
-
UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
|
828
|
-
RETURN NEW;
|
829
|
-
END IF;
|
830
|
-
|
831
|
-
_now := timezone('UTC', now());
|
832
|
-
_hid := NULL;
|
833
|
-
|
834
|
-
#{"SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;" unless ENV['CHRONOMODEL_NO_SQUASH']}
|
835
|
-
|
836
|
-
IF _hid IS NOT NULL THEN
|
837
|
-
UPDATE #{history} SET ( #{fields} ) = ( #{values} ) WHERE hid = _hid;
|
838
|
-
ELSE
|
839
|
-
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
840
|
-
WHERE #{pk} = OLD.#{pk} AND upper_inf(validity);
|
841
|
-
|
842
|
-
INSERT INTO #{history} ( #{pk}, #{fields}, validity )
|
843
|
-
VALUES ( OLD.#{pk}, #{values}, tsrange(_now, NULL) );
|
844
|
-
END IF;
|
845
|
-
|
846
|
-
UPDATE ONLY #{current} SET ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
|
847
|
-
|
848
|
-
RETURN NEW;
|
849
|
-
END;
|
850
|
-
$$ LANGUAGE plpgsql;
|
851
|
-
|
852
|
-
DROP TRIGGER IF EXISTS chronomodel_update ON #{table};
|
853
|
-
|
854
|
-
CREATE TRIGGER chronomodel_update INSTEAD OF UPDATE ON #{table}
|
855
|
-
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_update();
|
856
|
-
SQL
|
857
|
-
|
858
|
-
# DELETE - save the current data in the history and eventually delete the
|
859
|
-
# data from the temporal table.
|
860
|
-
# The first DELETE is required to remove history for records INSERTed and
|
861
|
-
# DELETEd in the same transaction.
|
862
|
-
#
|
863
|
-
execute <<-SQL
|
864
|
-
CREATE OR REPLACE FUNCTION chronomodel_#{table}_delete() RETURNS TRIGGER AS $$
|
865
|
-
DECLARE _now timestamp;
|
866
|
-
BEGIN
|
867
|
-
_now := timezone('UTC', now());
|
868
|
-
|
869
|
-
DELETE FROM #{history}
|
870
|
-
WHERE #{pk} = old.#{pk} AND validity = tsrange(_now, NULL);
|
871
|
-
|
872
|
-
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
873
|
-
WHERE #{pk} = old.#{pk} AND upper_inf(validity);
|
874
|
-
|
875
|
-
DELETE FROM ONLY #{current}
|
876
|
-
WHERE #{pk} = old.#{pk};
|
877
|
-
|
878
|
-
RETURN OLD;
|
879
|
-
END;
|
880
|
-
$$ LANGUAGE plpgsql;
|
881
|
-
|
882
|
-
DROP TRIGGER IF EXISTS chronomodel_delete ON #{table};
|
883
|
-
|
884
|
-
CREATE TRIGGER chronomodel_delete INSTEAD OF DELETE ON #{table}
|
885
|
-
FOR EACH ROW EXECUTE PROCEDURE chronomodel_#{table}_delete();
|
886
|
-
SQL
|
887
|
-
end
|
888
|
-
|
889
|
-
def chrono_drop_trigger_functions_for(table_name)
|
890
|
-
%w( insert update delete ).each do |func|
|
891
|
-
execute "DROP FUNCTION IF EXISTS chronomodel_#{table_name}_#{func}()"
|
892
|
-
end
|
893
|
-
end
|
894
|
-
|
895
|
-
# In destructive changes, such as removing columns or changing column
|
896
|
-
# types, the view must be dropped and recreated, while the change has
|
897
|
-
# to be applied to the table in the temporal schema.
|
898
|
-
#
|
899
|
-
def chrono_alter(table_name, opts = {})
|
900
|
-
transaction do
|
901
|
-
options = chrono_metadata_for(table_name).merge(opts)
|
902
|
-
|
903
|
-
execute "DROP VIEW #{table_name}"
|
904
|
-
|
905
|
-
_on_temporal_schema { yield }
|
906
|
-
|
907
|
-
# Recreate the triggers
|
908
|
-
chrono_create_view_for(table_name, options)
|
909
|
-
end
|
910
|
-
end
|
911
|
-
|
912
|
-
# Generic alteration of history tables, where changes have to be
|
913
|
-
# propagated both on the temporal table and the history one.
|
914
|
-
#
|
915
|
-
# Internally, the :on_current_schema bypasses the +is_chrono?+
|
916
|
-
# check, as some temporal indexes and constraints are created
|
917
|
-
# only on the history table, and the creation methods already
|
918
|
-
# run scoped into the correct schema.
|
919
|
-
#
|
920
|
-
def chrono_alter_index(table_name, options)
|
921
|
-
if is_chrono?(table_name) && !options[:on_current_schema]
|
922
|
-
_on_temporal_schema { yield }
|
923
|
-
_on_history_schema { yield }
|
924
|
-
else
|
925
|
-
yield
|
926
|
-
end
|
927
|
-
end
|
928
|
-
|
929
|
-
def chrono_alter_constraint(table_name, options)
|
930
|
-
if is_chrono?(table_name) && !options[:on_current_schema]
|
931
|
-
_on_temporal_schema { yield }
|
932
|
-
else
|
933
|
-
yield
|
934
|
-
end
|
935
|
-
end
|
936
|
-
|
937
|
-
def chrono_data_source_exists?(table_name)
|
938
|
-
if ActiveRecord::VERSION::MAJOR >= 5
|
939
|
-
data_source_exists?(table_name)
|
940
|
-
else
|
941
|
-
# On Rails 4, table_exists? has the same behaviour, checking if both
|
942
|
-
# a view or table exists
|
943
|
-
table_exists?(table_name)
|
944
|
-
end
|
945
|
-
end
|
946
|
-
|
947
|
-
def _on_temporal_schema(nesting = true, &block)
|
948
|
-
on_schema(TEMPORAL_SCHEMA, nesting, &block)
|
949
|
-
end
|
950
|
-
|
951
|
-
def _on_history_schema(nesting = true, &block)
|
952
|
-
on_schema(HISTORY_SCHEMA, nesting, &block)
|
953
|
-
end
|
954
|
-
|
955
|
-
def translate_exception(exception, message)
|
956
|
-
if exception.message =~ /conflicting key value violates exclusion constraint/
|
957
|
-
ActiveRecord::RecordNotUnique.new(message)
|
958
|
-
else
|
959
|
-
super
|
960
|
-
end
|
961
|
-
end
|
962
179
|
end
|
963
180
|
|
964
181
|
end
|