chrono_model 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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