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.
Files changed (38) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +19 -14
  3. data/README.md +49 -25
  4. data/lib/chrono_model.rb +37 -3
  5. data/lib/chrono_model/adapter.rb +91 -874
  6. data/lib/chrono_model/adapter/ddl.rb +225 -0
  7. data/lib/chrono_model/adapter/indexes.rb +194 -0
  8. data/lib/chrono_model/adapter/migrations.rb +282 -0
  9. data/lib/chrono_model/adapter/tsrange.rb +57 -0
  10. data/lib/chrono_model/adapter/upgrade.rb +120 -0
  11. data/lib/chrono_model/conversions.rb +20 -0
  12. data/lib/chrono_model/json.rb +28 -0
  13. data/lib/chrono_model/patches.rb +8 -232
  14. data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
  15. data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
  16. data/lib/chrono_model/patches/association.rb +52 -0
  17. data/lib/chrono_model/patches/db_console.rb +11 -0
  18. data/lib/chrono_model/patches/join_node.rb +32 -0
  19. data/lib/chrono_model/patches/preloader.rb +68 -0
  20. data/lib/chrono_model/patches/relation.rb +58 -0
  21. data/lib/chrono_model/time_gate.rb +5 -5
  22. data/lib/chrono_model/time_machine.rb +47 -427
  23. data/lib/chrono_model/time_machine/history_model.rb +196 -0
  24. data/lib/chrono_model/time_machine/time_query.rb +86 -0
  25. data/lib/chrono_model/time_machine/timeline.rb +94 -0
  26. data/lib/chrono_model/utilities.rb +27 -0
  27. data/lib/chrono_model/version.rb +1 -1
  28. data/spec/aruba/dbconsole_spec.rb +25 -0
  29. data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
  30. data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
  31. data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
  32. data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
  33. data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
  34. data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
  35. data/spec/config.travis.yml +1 -0
  36. data/spec/config.yml.example +1 -0
  37. metadata +35 -14
  38. data/lib/chrono_model/utils.rb +0 -117
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: d1cb1ec2f0637ba61fce647853f9e2d1690214fd
4
- data.tar.gz: 0dd9231715441b128fa68599a2594dd2847fd449
2
+ SHA256:
3
+ metadata.gz: 143b16e6e193bd56c88d1541f965150b07af191992aec7978dc94c84d7e53f87
4
+ data.tar.gz: 4aa06ed4110a6dbd9047f580b2972589d813f5108b422fe6ba0a80c021d56d03
5
5
  SHA512:
6
- metadata.gz: 418fc68a58518d1bf8a9513f56f26598cdb195db0ceb7e8d6fa3b06061a9ca0f26f436462b1dfb666c47a1d549bbafea80be852ccb4cdd7081a203b1ca524844
7
- data.tar.gz: c1ecc4413a82074372edddf75c86318f4e8995c6dcb3a4775419d7ee9b431eb37f4eadcb31ca3ac41e7d69bcbf4532044e681b001c65c481110e759442ef954d
6
+ metadata.gz: 57a62853b840b3edd78d1e0402c8d66458b01980956c87c880b84261490ed6f251a00b497c70f56646b4dd051869554118fc426d31b9bfda0af367e8977f3612
7
+ data.tar.gz: 854b001d29648c4b191eb7e985d720d72bd82cd43dc40df481cc63a83d684fe15922b8127ea40418ad89e5cc7cee30bb3e2551e2eeb6964bbb6477a97aa2f109
@@ -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
- before_script:
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
- ![{chronos - greek god of time}][chronos-image]
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 --with-python postgres
71
+ brew install postgres
77
72
 
78
- With Apt:
73
+ With apt:
79
74
 
80
- apt-get install postgresql-plpython
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.3 instance. Create `spec/config.yml` with the
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
- Run `rake`. SQL queries are logged to `spec/debug.log`. If you want to see them
288
- in your output, use `rake VERBOSE=true`.
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
- [JSON][pg-json-type] does not provide an [equality operator][pg-json-func].
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 a naive
298
- comparison of JSON objects [implemented in pl/python][pg-json-opclass].
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 load the opclass you can use the `ChronoModel::Json.create`
301
- convenience method. If you don't use JSON don't bother doing this.
326
+ To set up you can use
302
327
 
303
- If you are on Postgres 9.4, you are **strongly encouraged to use JSONB**,
304
- as it has an equality operator built-in, it's faster and stricter, and
305
- offers many more indexing abilities and better performance than JSON.
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
- [chronos-image]: http://i.imgur.com/8NObYiZl.jpg
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
@@ -1,14 +1,18 @@
1
- require 'chrono_model/version'
2
- require 'chrono_model/adapter'
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/utils'
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
@@ -1,7 +1,10 @@
1
- require 'active_record'
2
1
  require 'active_record/connection_adapters/postgresql_adapter'
3
2
 
4
- require 'multi_json'
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
- # Creates the given table, possibly creating the temporal schema
31
- # objects if the `:temporal` option is given and set to true.
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
- super table_name, options, &block
184
- end
185
- end
39
+ chrono_upgrade_warning
186
40
  end
187
41
 
188
- # If dropping a temporal table, drops it from the temporal schema
189
- # adding the CASCADE option so to delete the history, view and triggers.
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
- def drop_table(table_name, *)
192
- return super unless is_chrono?(table_name)
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
- def add_index(table_name, column_name, options = {})
204
- return super unless is_chrono?(table_name)
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
- def remove_index(table_name, *)
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 add_column(table_name, *)
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
- # If renaming a column of a temporal table, rename it in the table in
244
- # the temporal schema and update the triggers.
245
- #
246
- def rename_column(table_name, *)
247
- return super unless is_chrono?(table_name)
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
- define_method(:column_definitions) do |table_name|
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
- [:primary_key, :indexes, :default_sequence_name].each do |method|
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
- def remove_temporal_indexes(table, range, options = {})
354
- indexes = temporal_index_names(table, range, options)
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 add_timeline_consistency_constraint(table, range, options = {})
379
- name = timeline_consistency_constraint_name(table)
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
- def timeline_consistency_constraint_name(table)
401
- "#{table}_timeline_consistency"
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
- # By default, nested call are allowed, to disable this feature
408
- # pass +false+ as the second parameter.
98
+ # Recursion works by saving the old_path the function closure
99
+ # at each recursive call.
409
100
  #
410
- def on_schema(schema, nesting = true, &block)
411
- @_on_schema_nesting = (@_on_schema_nesting || 0) + 1
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
- if nesting || @_on_schema_nesting == 1
414
- old_path = self.schema_search_path
415
- self.schema_search_path = schema
416
- end
106
+ count_recursions do
107
+ if recurse == :follow or Thread.current['recursions'] == 1
108
+ self.schema_search_path = schema
109
+ end
417
110
 
418
- block.call
111
+ yield
112
+ end
419
113
 
420
114
  ensure
421
- if (nesting || @_on_schema_nesting == 1)
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
- # If the transaction is aborted, any execute() call will raise
424
- # "transaction is aborted errors" - thus calling the Adapter's
425
- # setter won't update the memoized variable.
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
- _on_temporal_schema { chrono_data_source_exists?(table) } &&
444
- _on_history_schema { chrono_data_source_exists?(table) }
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
- def is_exception_class?(e, *klasses)
457
- if e.respond_to?(:original_exception)
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
- # https://github.com/rails/rails/issues/13793
486
- # https://github.com/rails/rails/issues/14010
487
- #
488
- # so, for now, we are implementing our own.
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
- [from, to]
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
- def initialize_type_map(m = type_map)
515
- super.tap do
516
- ar_type = type_map.fetch(TSRange::OID)
517
- cm_type = TSRange.new(ar_type.subtype, ar_type.type)
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
- type_map.register_type TSRange::OID, cm_type
520
- end
156
+ execute %[ COMMENT ON VIEW #{view_name} IS #{quote(comment)} ]
521
157
  end
522
158
 
523
- # Copy the indexes from the temporal table to the history table if the indexes
524
- # are not already created with the same name.
525
- #
526
- # Uniqueness is voluntarily ignored, as it doesn't make sense on history
527
- # tables.
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
- temporal_indexes.each do |index|
536
- next if history_indexes.include?(index.name)
166
+ yield
537
167
 
538
- _on_history_schema do
539
- execute %[
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