chrono_model 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +82 -12
- data/lib/active_record/connection_adapters/chronomodel_adapter.rb +1 -1
- data/lib/active_record/tasks/chronomodel_database_tasks.rb +15 -2
- data/lib/chrono_model/adapter/ddl.rb +15 -16
- data/lib/chrono_model/adapter/indexes.rb +13 -15
- data/lib/chrono_model/adapter/migrations.rb +9 -11
- data/lib/chrono_model/adapter/migrations_modules/stable.rb +1 -1
- data/lib/chrono_model/adapter/upgrade.rb +6 -7
- data/lib/chrono_model/adapter.rb +9 -14
- data/lib/chrono_model/conversions.rb +0 -2
- data/lib/chrono_model/db_console.rb +1 -1
- data/lib/chrono_model/patches/association.rb +4 -4
- data/lib/chrono_model/patches/batches.rb +35 -1
- data/lib/chrono_model/patches/join_node.rb +6 -17
- data/lib/chrono_model/patches/preloader.rb +7 -33
- data/lib/chrono_model/patches/relation.rb +69 -31
- data/lib/chrono_model/patches.rb +7 -7
- data/lib/chrono_model/railtie.rb +1 -1
- data/lib/chrono_model/time_machine/history_model.rb +34 -8
- data/lib/chrono_model/time_machine/time_query.rb +2 -2
- data/lib/chrono_model/time_machine/timeline.rb +3 -3
- data/lib/chrono_model/time_machine.rb +5 -5
- data/lib/chrono_model/utilities.rb +3 -3
- data/lib/chrono_model/version.rb +1 -1
- data/lib/chrono_model.rb +14 -9
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6465bfc1eca17fb499f1826a7b938969bc7306b388ac93bce746a09c75a2c1a4
|
|
4
|
+
data.tar.gz: 6958a3018c4a29dbd4b81a56c60bc02d4fc8696780b62fe1ec8778725152e12d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f0cf88eb3e21ec2cee85ba744a426e5ab125a71c18e4c7cbe31b45cbb5198f8f8dcbd1e17b87766cf3b386ebaf789fcb9dfd5190bb7e47bf61c3d9824fda490
|
|
7
|
+
data.tar.gz: e852d433e7d5c28f69e50511d0f094ae16fcb0f28cfc825273485c607b6e5fb2adc6d9cc82f0ec78c680c6e07392f296bfa27afcfe54d05595c3ac04a1a9e2cb
|
data/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
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
|
-
[![Code Climate][code-analysis-badge]][code-analysis]
|
|
5
|
-
[![Test Coverage][test-coverage-badge]][test-coverage]
|
|
6
4
|
[![Gem Version][gem-version-badge]][gem-version]
|
|
7
5
|
[![Inlinedocs][docs-analysis-badge]][docs-analysis]
|
|
8
6
|
|
|
@@ -98,6 +96,79 @@ format:
|
|
|
98
96
|
config.active_record.schema_format = :sql
|
|
99
97
|
```
|
|
100
98
|
|
|
99
|
+
## Database Permissions (PostgreSQL)
|
|
100
|
+
|
|
101
|
+
ChronoModel creates and manages data in the `temporal` and `history` schemas. Your application database user needs appropriate privileges on these schemas and their objects.
|
|
102
|
+
|
|
103
|
+
### Required Privileges
|
|
104
|
+
|
|
105
|
+
Grant the following privileges to your application database user (replace `app_user` with your actual username):
|
|
106
|
+
|
|
107
|
+
```sql
|
|
108
|
+
-- Schema access
|
|
109
|
+
GRANT USAGE ON SCHEMA temporal TO app_user;
|
|
110
|
+
GRANT USAGE ON SCHEMA history TO app_user;
|
|
111
|
+
|
|
112
|
+
-- Table privileges for existing objects
|
|
113
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA temporal TO app_user;
|
|
114
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA history TO app_user;
|
|
115
|
+
|
|
116
|
+
-- Sequence privileges for existing objects
|
|
117
|
+
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA temporal TO app_user;
|
|
118
|
+
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA history TO app_user;
|
|
119
|
+
|
|
120
|
+
-- Default privileges for future objects
|
|
121
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA temporal GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
|
122
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA history GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
|
123
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA temporal GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO app_user;
|
|
124
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA history GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO app_user;
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Quick Diagnostics
|
|
128
|
+
|
|
129
|
+
You can verify your privileges are correctly set up by running these queries as your application user:
|
|
130
|
+
|
|
131
|
+
```sql
|
|
132
|
+
-- Check schema access
|
|
133
|
+
SELECT
|
|
134
|
+
schema_name,
|
|
135
|
+
has_schema_privilege(current_user, schema_name, 'USAGE') AS has_usage
|
|
136
|
+
FROM information_schema.schemata
|
|
137
|
+
WHERE schema_name IN ('temporal', 'history');
|
|
138
|
+
|
|
139
|
+
-- Check table privileges (run after creating temporal tables)
|
|
140
|
+
SELECT
|
|
141
|
+
schemaname,
|
|
142
|
+
tablename,
|
|
143
|
+
has_table_privilege(current_user, schemaname||'.'||tablename, 'SELECT') AS has_select,
|
|
144
|
+
has_table_privilege(current_user, schemaname||'.'||tablename, 'INSERT') AS has_insert,
|
|
145
|
+
has_table_privilege(current_user, schemaname||'.'||tablename, 'UPDATE') AS has_update,
|
|
146
|
+
has_table_privilege(current_user, schemaname||'.'||tablename, 'DELETE') AS has_delete
|
|
147
|
+
FROM pg_tables
|
|
148
|
+
WHERE schemaname IN ('temporal', 'history');
|
|
149
|
+
|
|
150
|
+
-- Check sequence privileges (run after creating temporal tables)
|
|
151
|
+
SELECT
|
|
152
|
+
schemaname,
|
|
153
|
+
sequencename,
|
|
154
|
+
has_sequence_privilege(current_user, schemaname||'.'||sequencename, 'USAGE') AS has_usage,
|
|
155
|
+
has_sequence_privilege(current_user, schemaname||'.'||sequencename, 'SELECT') AS has_select,
|
|
156
|
+
has_sequence_privilege(current_user, schemaname||'.'||sequencename, 'UPDATE') AS has_update
|
|
157
|
+
FROM pg_sequences
|
|
158
|
+
WHERE schemaname IN ('temporal', 'history');
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Troubleshooting
|
|
162
|
+
|
|
163
|
+
If you encounter these symptoms, check your database permissions:
|
|
164
|
+
|
|
165
|
+
- **`ActiveRecord::UnknownPrimaryKey`** errors on temporalized models
|
|
166
|
+
- **`Model.chrono?` returns `false`** for models that should be temporal
|
|
167
|
+
- **Unexpected primary key or temporalization issues** during schema operations
|
|
168
|
+
- **Permission denied errors** when ChronoModel tries to access temporal/history objects
|
|
169
|
+
|
|
170
|
+
These issues often indicate insufficient privileges on the `temporal` and `history` schemas. Run the diagnostic queries above to identify missing privileges, then apply the appropriate `GRANT` statements.
|
|
171
|
+
|
|
101
172
|
## Schema creation
|
|
102
173
|
|
|
103
174
|
ChronoModel hooks all `ActiveRecord::Migration` methods to make them temporal
|
|
@@ -146,8 +217,7 @@ the `:validity` option:
|
|
|
146
217
|
change_table :your_table, temporal: true, copy_data: true, validity: '1977-01-01'
|
|
147
218
|
```
|
|
148
219
|
|
|
149
|
-
Please note that `change_table` requires you to use
|
|
150
|
-
`down` migrations. It cannot work with Rails 3-style `change` migrations.
|
|
220
|
+
Please note that `change_table` requires you to use `up` and `down` migrations.
|
|
151
221
|
|
|
152
222
|
|
|
153
223
|
## Selective Journaling
|
|
@@ -347,9 +417,6 @@ Ensure to run the full test suite before pushing.
|
|
|
347
417
|
|
|
348
418
|
* Foreign keys are not supported. [See issue #174][gh-issue-174]
|
|
349
419
|
|
|
350
|
-
* There may be unexpected results when combining eager loading and joins.
|
|
351
|
-
[See issue #186][gh-issue-186]
|
|
352
|
-
|
|
353
420
|
* Global ID ignores historical objects. [See issue #192][gh-issue-192]
|
|
354
421
|
|
|
355
422
|
* Different historical objects are considered the identical. [See issue
|
|
@@ -359,6 +426,12 @@ Ensure to run the full test suite before pushing.
|
|
|
359
426
|
creates a new record for each modification. This will lead to increased
|
|
360
427
|
storage requirements and bloated history
|
|
361
428
|
|
|
429
|
+
* `*_by_sql` query methods are not supported. [See issue #313][gh-issue-313]
|
|
430
|
+
|
|
431
|
+
* `self.table_name` must be set before `include ChronoModel::TimeMachine`.
|
|
432
|
+
[See issue #336][gh-issue-336]
|
|
433
|
+
|
|
434
|
+
|
|
362
435
|
## Contributing
|
|
363
436
|
|
|
364
437
|
1. Fork it
|
|
@@ -383,14 +456,10 @@ This software is Made in Italy :it: :smile:.
|
|
|
383
456
|
|
|
384
457
|
[build-status]: https://github.com/ifad/chronomodel/actions
|
|
385
458
|
[build-status-badge]: https://github.com/ifad/chronomodel/actions/workflows/ruby.yml/badge.svg
|
|
386
|
-
[code-analysis]: https://codeclimate.com/github/ifad/chronomodel/maintainability
|
|
387
|
-
[code-analysis-badge]: https://api.codeclimate.com/v1/badges/cdee7327938dc2eaff99/maintainability
|
|
388
459
|
[docs-analysis]: https://inch-ci.org/github/ifad/chronomodel
|
|
389
460
|
[docs-analysis-badge]: https://inch-ci.org/github/ifad/chronomodel.svg?branch=master
|
|
390
461
|
[gem-version]: https://rubygems.org/gems/chrono_model
|
|
391
462
|
[gem-version-badge]: https://badge.fury.io/rb/chrono_model.svg
|
|
392
|
-
[test-coverage]: https://codeclimate.com/github/ifad/chronomodel
|
|
393
|
-
[test-coverage-badge]: https://codeclimate.com/github/ifad/chronomodel/badges/coverage.svg
|
|
394
463
|
|
|
395
464
|
[delorean-image]: https://i.imgur.com/DD77F4s.jpg
|
|
396
465
|
|
|
@@ -422,6 +491,7 @@ This software is Made in Italy :it: :smile:.
|
|
|
422
491
|
|
|
423
492
|
[gh-pzac]: https://github.com/pzac
|
|
424
493
|
[gh-issue-174]: https://github.com/ifad/chronomodel/issues/174
|
|
425
|
-
[gh-issue-186]: https://github.com/ifad/chronomodel/issues/186
|
|
426
494
|
[gh-issue-192]: https://github.com/ifad/chronomodel/issues/192
|
|
427
495
|
[gh-issue-206]: https://github.com/ifad/chronomodel/issues/206
|
|
496
|
+
[gh-issue-313]: https://github.com/ifad/chronomodel/issues/313
|
|
497
|
+
[gh-issue-336]: https://github.com/ifad/chronomodel/issues/336
|
|
@@ -32,7 +32,7 @@ module ActiveRecord
|
|
|
32
32
|
args = ['-c', '-f', target.to_s]
|
|
33
33
|
args << chronomodel_configuration[:database]
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
run_cmd_with_compatibility('pg_dump', args, 'dumping data')
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def data_load(source)
|
|
@@ -41,7 +41,7 @@ module ActiveRecord
|
|
|
41
41
|
args = ['-f', source]
|
|
42
42
|
args << chronomodel_configuration[:database]
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
run_cmd_with_compatibility('psql', args, 'loading data')
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
private
|
|
@@ -50,6 +50,19 @@ module ActiveRecord
|
|
|
50
50
|
@chronomodel_configuration ||= @configuration_hash
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# TODO: replace `run_cmd_with_compatibility` with `run_cmd` and remove when dropping Rails < 8.1 support
|
|
54
|
+
# Compatibility method to handle Rails version differences in run_cmd signature
|
|
55
|
+
# Rails < 8.1: run_cmd(cmd, args, action)
|
|
56
|
+
# Rails >= 8.1: run_cmd(cmd, *args, **opts)
|
|
57
|
+
def run_cmd_with_compatibility(cmd, args, action_description)
|
|
58
|
+
# Check if run_cmd method accepts keyword arguments (new signature)
|
|
59
|
+
if method(:run_cmd).parameters.any? { |type, _name| type == :rest }
|
|
60
|
+
run_cmd(cmd, *args)
|
|
61
|
+
else
|
|
62
|
+
run_cmd(cmd, args, action_description)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
# If a schema search path is defined in the configuration file, it will
|
|
54
67
|
# be used by the database tasks class to dump only the specified search
|
|
55
68
|
# path. Here we add also ChronoModel's temporal and history schemas to
|
|
@@ -55,12 +55,12 @@ module ChronoModel
|
|
|
55
55
|
parent = "#{TEMPORAL_SCHEMA}.#{table}"
|
|
56
56
|
p_pkey = primary_key(parent)
|
|
57
57
|
|
|
58
|
-
execute
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
execute <<~SQL.squish
|
|
59
|
+
CREATE TABLE #{table} (
|
|
60
|
+
hid BIGSERIAL PRIMARY KEY,
|
|
61
|
+
validity tsrange NOT NULL,
|
|
62
|
+
recorded_at timestamp NOT NULL DEFAULT timezone('UTC', now())
|
|
63
|
+
) INHERITS (#{parent})
|
|
64
64
|
SQL
|
|
65
65
|
|
|
66
66
|
add_history_validity_constraint(table, p_pkey)
|
|
@@ -85,11 +85,11 @@ module ChronoModel
|
|
|
85
85
|
execute <<-SQL.strip_heredoc # rubocop:disable Rails/SquishedSQLHeredocs,Rails/StripHeredoc
|
|
86
86
|
CREATE OR REPLACE FUNCTION chronomodel_#{table}_insert() RETURNS TRIGGER AS $$
|
|
87
87
|
BEGIN
|
|
88
|
-
#{insert_sequence_sql(pk, current)} INTO #{current} (
|
|
89
|
-
VALUES (
|
|
88
|
+
#{insert_sequence_sql(pk, current)} INTO #{current} (#{pk}, #{fields})
|
|
89
|
+
VALUES (NEW.#{pk}, #{values});
|
|
90
90
|
|
|
91
|
-
INSERT INTO #{history} (
|
|
92
|
-
VALUES (
|
|
91
|
+
INSERT INTO #{history} (#{pk}, #{fields}, validity)
|
|
92
|
+
VALUES (NEW.#{pk}, #{values}, tsrange(timezone('UTC', now()), NULL));
|
|
93
93
|
|
|
94
94
|
RETURN NEW;
|
|
95
95
|
END;
|
|
@@ -150,7 +150,7 @@ module ChronoModel
|
|
|
150
150
|
_new := row(#{journal.map { |c| "NEW.#{c}" }.join(', ')});
|
|
151
151
|
|
|
152
152
|
IF _old IS NOT DISTINCT FROM _new THEN
|
|
153
|
-
UPDATE ONLY #{current} SET (
|
|
153
|
+
UPDATE ONLY #{current} SET (#{fields}) = (#{values}) WHERE #{pk} = OLD.#{pk};
|
|
154
154
|
RETURN NEW;
|
|
155
155
|
END IF;
|
|
156
156
|
|
|
@@ -160,16 +160,16 @@ module ChronoModel
|
|
|
160
160
|
#{"SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;" unless ENV['CHRONOMODEL_NO_SQUASH']}
|
|
161
161
|
|
|
162
162
|
IF _hid IS NOT NULL THEN
|
|
163
|
-
UPDATE #{history} SET (
|
|
163
|
+
UPDATE #{history} SET (#{fields}) = (#{values}) WHERE hid = _hid;
|
|
164
164
|
ELSE
|
|
165
165
|
UPDATE #{history} SET validity = tsrange(lower(validity), _now)
|
|
166
166
|
WHERE #{pk} = OLD.#{pk} AND upper_inf(validity);
|
|
167
167
|
|
|
168
|
-
INSERT INTO #{history} (
|
|
169
|
-
VALUES (
|
|
168
|
+
INSERT INTO #{history} (#{pk}, #{fields}, validity)
|
|
169
|
+
VALUES (OLD.#{pk}, #{values}, tsrange(_now, NULL));
|
|
170
170
|
END IF;
|
|
171
171
|
|
|
172
|
-
UPDATE ONLY #{current} SET (
|
|
172
|
+
UPDATE ONLY #{current} SET (#{fields}) = (#{values}) WHERE #{pk} = OLD.#{pk};
|
|
173
173
|
|
|
174
174
|
RETURN NEW;
|
|
175
175
|
END;
|
|
@@ -234,7 +234,6 @@ module ChronoModel
|
|
|
234
234
|
INSERT
|
|
235
235
|
SQL
|
|
236
236
|
end
|
|
237
|
-
# private
|
|
238
237
|
end
|
|
239
238
|
end
|
|
240
239
|
end
|
|
@@ -24,15 +24,13 @@ module ChronoModel
|
|
|
24
24
|
temporal_index_names(table, range, options)
|
|
25
25
|
|
|
26
26
|
chrono_alter_index(table, options) do
|
|
27
|
-
execute
|
|
28
|
-
CREATE INDEX #{range_idx} ON #{table} USING gist ( #{range} )
|
|
29
|
-
SQL
|
|
27
|
+
execute "CREATE INDEX #{range_idx} ON #{table} USING gist (#{range})"
|
|
30
28
|
|
|
31
29
|
# Indexes used for precise history filtering, sorting and, in history
|
|
32
30
|
# tables, by UPDATE / DELETE triggers.
|
|
33
31
|
#
|
|
34
|
-
execute "CREATE INDEX #{lower_idx} ON #{table} (
|
|
35
|
-
execute "CREATE INDEX #{upper_idx} ON #{table} (
|
|
32
|
+
execute "CREATE INDEX #{lower_idx} ON #{table} (lower(#{range}))"
|
|
33
|
+
execute "CREATE INDEX #{upper_idx} ON #{table} (upper(#{range}))"
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
@@ -53,9 +51,9 @@ module ChronoModel
|
|
|
53
51
|
id = options[:id] || primary_key(table)
|
|
54
52
|
|
|
55
53
|
chrono_alter_constraint(table, options) do
|
|
56
|
-
execute
|
|
54
|
+
execute <<~SQL.squish
|
|
57
55
|
ALTER TABLE #{table} ADD CONSTRAINT #{name}
|
|
58
|
-
EXCLUDE USING gist (
|
|
56
|
+
EXCLUDE USING gist (#{id} WITH =, #{range} WITH &&)
|
|
59
57
|
SQL
|
|
60
58
|
end
|
|
61
59
|
end
|
|
@@ -64,7 +62,7 @@ module ChronoModel
|
|
|
64
62
|
name = timeline_consistency_constraint_name(table)
|
|
65
63
|
|
|
66
64
|
chrono_alter_constraint(table, options) do
|
|
67
|
-
execute
|
|
65
|
+
execute <<~SQL.squish
|
|
68
66
|
ALTER TABLE #{table} DROP CONSTRAINT #{name}
|
|
69
67
|
SQL
|
|
70
68
|
end
|
|
@@ -81,9 +79,9 @@ module ChronoModel
|
|
|
81
79
|
def chrono_create_history_indexes_for(table, p_pkey)
|
|
82
80
|
add_temporal_indexes table, :validity, on_current_schema: true
|
|
83
81
|
|
|
84
|
-
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} (
|
|
85
|
-
execute "CREATE INDEX #{table}_recorded_at ON #{table} (
|
|
86
|
-
execute "CREATE INDEX #{table}_instance_history ON #{table} (
|
|
82
|
+
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} (#{p_pkey})"
|
|
83
|
+
execute "CREATE INDEX #{table}_recorded_at ON #{table} (recorded_at)"
|
|
84
|
+
execute "CREATE INDEX #{table}_instance_history ON #{table} (#{p_pkey}, recorded_at)"
|
|
87
85
|
end
|
|
88
86
|
|
|
89
87
|
# Rename indexes on history schema
|
|
@@ -144,10 +142,10 @@ module ChronoModel
|
|
|
144
142
|
#
|
|
145
143
|
columns = Array.wrap(index.columns).join(', ')
|
|
146
144
|
|
|
147
|
-
execute
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
execute <<~SQL.squish, 'Copy index from temporal to history'
|
|
146
|
+
CREATE INDEX #{index.name} ON #{table_name}
|
|
147
|
+
USING #{index.using} (#{columns})
|
|
148
|
+
SQL
|
|
151
149
|
end
|
|
152
150
|
end
|
|
153
151
|
end
|
|
@@ -84,7 +84,7 @@ module ChronoModel
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
else
|
|
87
|
-
if is_chrono?(table_name)
|
|
87
|
+
if is_chrono?(table_name) && options[:temporal] == false
|
|
88
88
|
chrono_undo_temporal_table(table_name)
|
|
89
89
|
end
|
|
90
90
|
|
|
@@ -214,14 +214,14 @@ module ChronoModel
|
|
|
214
214
|
seq = on_history_schema { pk_and_sequence_for(table_name).last.to_s }
|
|
215
215
|
from = options[:validity] || '0001-01-01 00:00:00'
|
|
216
216
|
|
|
217
|
-
execute
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
execute <<~SQL.squish
|
|
218
|
+
INSERT INTO #{HISTORY_SCHEMA}.#{table_name}
|
|
219
|
+
SELECT *,
|
|
220
|
+
nextval('#{seq}') AS hid,
|
|
221
|
+
tsrange('#{from}', NULL) AS validity,
|
|
222
|
+
timezone('UTC', now()) AS recorded_at
|
|
223
|
+
FROM #{TEMPORAL_SCHEMA}.#{table_name}
|
|
224
|
+
SQL
|
|
225
225
|
end
|
|
226
226
|
|
|
227
227
|
# Removes temporal features from this table
|
|
@@ -252,8 +252,6 @@ module ChronoModel
|
|
|
252
252
|
execute "ALTER SEQUENCE #{seq} RENAME TO #{new_seq}"
|
|
253
253
|
execute "ALTER TABLE #{name} RENAME TO #{new_name}"
|
|
254
254
|
end
|
|
255
|
-
|
|
256
|
-
# private
|
|
257
255
|
end
|
|
258
256
|
end
|
|
259
257
|
end
|
|
@@ -17,7 +17,7 @@ module ChronoModel
|
|
|
17
17
|
# Uniqueness constraints do not make sense in the history table
|
|
18
18
|
options = options.dup.tap { |o| o.delete(:unique) } if options[:unique].present?
|
|
19
19
|
|
|
20
|
-
on_history_schema { super
|
|
20
|
+
on_history_schema { super }
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -81,12 +81,12 @@ module ChronoModel
|
|
|
81
81
|
p_pkey = primary_key(table_name)
|
|
82
82
|
|
|
83
83
|
execute "ALTER TABLE #{history_table} ADD COLUMN validity tsrange;"
|
|
84
|
-
execute
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
execute <<~SQL.squish
|
|
85
|
+
UPDATE #{history_table} SET validity = tsrange(valid_from,
|
|
86
|
+
CASE WHEN extract(year from valid_to) = 9999 THEN NULL
|
|
87
|
+
ELSE valid_to
|
|
88
|
+
END
|
|
89
|
+
);
|
|
90
90
|
SQL
|
|
91
91
|
|
|
92
92
|
execute "DROP INDEX #{history_table}_temporal_on_valid_from;"
|
|
@@ -112,7 +112,6 @@ module ChronoModel
|
|
|
112
112
|
on_history_schema { add_history_validity_constraint(table_name, p_pkey) }
|
|
113
113
|
on_history_schema { chrono_create_history_indexes_for(table_name, p_pkey) }
|
|
114
114
|
end
|
|
115
|
-
# private
|
|
116
115
|
end
|
|
117
116
|
end
|
|
118
117
|
end
|
data/lib/chrono_model/adapter.rb
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'active_record/connection_adapters/postgresql_adapter'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
require_relative 'adapter/migrations'
|
|
6
|
+
require_relative 'adapter/migrations_modules/stable'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
require_relative 'adapter/ddl'
|
|
9
|
+
require_relative 'adapter/indexes'
|
|
10
|
+
require_relative 'adapter/upgrade'
|
|
11
11
|
|
|
12
12
|
module ChronoModel
|
|
13
13
|
# This class implements all ActiveRecord::ConnectionAdapters::SchemaStatements
|
|
@@ -87,15 +87,10 @@ module ChronoModel
|
|
|
87
87
|
# The default search path is included however, since the table
|
|
88
88
|
# may reference types defined in other schemas, which result in their
|
|
89
89
|
# names becoming schema qualified, which will cause type resolutions to fail.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
def column_definitions; end
|
|
94
|
-
|
|
95
|
-
define_method(:column_definitions) do |table_name|
|
|
96
|
-
return super(table_name) unless is_chrono?(table_name)
|
|
90
|
+
def column_definitions(table_name)
|
|
91
|
+
return super unless is_chrono?(table_name)
|
|
97
92
|
|
|
98
|
-
on_schema("#{TEMPORAL_SCHEMA},#{schema_search_path}", recurse: :ignore) { super
|
|
93
|
+
on_schema("#{TEMPORAL_SCHEMA},#{schema_search_path}", recurse: :ignore) { super }
|
|
99
94
|
end
|
|
100
95
|
|
|
101
96
|
# Evaluates the given block in the temporal schema.
|
|
@@ -170,7 +165,7 @@ module ChronoModel
|
|
|
170
165
|
def chrono_metadata_set(view_name, metadata)
|
|
171
166
|
comment = MultiJson.dump(metadata)
|
|
172
167
|
|
|
173
|
-
execute
|
|
168
|
+
execute "COMMENT ON VIEW #{view_name} IS #{quote(comment)}"
|
|
174
169
|
end
|
|
175
170
|
|
|
176
171
|
def valid_table_definition_options
|
|
@@ -11,10 +11,6 @@ module ChronoModel
|
|
|
11
11
|
# on the join model's (:through association) one.
|
|
12
12
|
#
|
|
13
13
|
module Association
|
|
14
|
-
def skip_statement_cache?(*)
|
|
15
|
-
super || _chrono_target?
|
|
16
|
-
end
|
|
17
|
-
|
|
18
14
|
# If the association class or the through association are ChronoModels,
|
|
19
15
|
# then fetches the records from a virtual table using a subquery scope
|
|
20
16
|
# to a specific timestamp.
|
|
@@ -36,6 +32,10 @@ module ChronoModel
|
|
|
36
32
|
|
|
37
33
|
private
|
|
38
34
|
|
|
35
|
+
def skip_statement_cache?(*)
|
|
36
|
+
super || _chrono_target?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
39
|
def _chrono_record?
|
|
40
40
|
owner.class.include?(ChronoModel::Patches::AsOfTimeHolder) && owner.as_of_time.present?
|
|
41
41
|
end
|
|
@@ -2,10 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
module ChronoModel
|
|
4
4
|
module Patches
|
|
5
|
+
# Overrides the default batch methods for historical models
|
|
6
|
+
#
|
|
7
|
+
# In the default implementation, `cursor` defaults to `primary_key`, which is 'id'.
|
|
8
|
+
# However, historical models need to use 'hid' instead of 'id'.
|
|
9
|
+
#
|
|
10
|
+
# This patch addresses an issue where `with_hid_pkey` is called after the cursor
|
|
11
|
+
# is already set, potentially leading to incorrect behavior.
|
|
12
|
+
#
|
|
13
|
+
# Notes:
|
|
14
|
+
# - `find_each` and `find_in_batches` internally utilize `in_batches`.
|
|
15
|
+
# However, in the upcoming Rails 8.0, this implementation will be
|
|
16
|
+
# insufficient due to a new conditional branch using `enum_for`.
|
|
17
|
+
# - This approach prevents specifying 'id' as a cursor for historical models.
|
|
18
|
+
# If 'id' is needed, it must be handled separately.
|
|
19
|
+
#
|
|
20
|
+
# See: ifad/chronomodel#321 for more context
|
|
5
21
|
module Batches
|
|
6
|
-
def
|
|
22
|
+
def find_each(**options)
|
|
7
23
|
return super unless try(:history?)
|
|
8
24
|
|
|
25
|
+
options[:cursor] = 'hid' if options[:cursor] == 'id'
|
|
26
|
+
|
|
27
|
+
with_hid_pkey { super }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_in_batches(**options)
|
|
31
|
+
return super unless try(:history?)
|
|
32
|
+
|
|
33
|
+
options[:cursor] = 'hid' if options[:cursor] == 'id'
|
|
34
|
+
|
|
35
|
+
with_hid_pkey { super }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def in_batches(**options)
|
|
39
|
+
return super unless try(:history?)
|
|
40
|
+
|
|
41
|
+
options[:cursor] = 'hid' if options[:cursor] == 'id'
|
|
42
|
+
|
|
9
43
|
with_hid_pkey { super }
|
|
10
44
|
end
|
|
11
45
|
end
|
|
@@ -2,28 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module ChronoModel
|
|
4
4
|
module Patches
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# point in time, we replace the join node with a SqlLiteral node
|
|
9
|
-
# that does not respond to the methods that AR expects.
|
|
10
|
-
#
|
|
11
|
-
# This class provides AR with an object implementing the methods
|
|
12
|
-
# it expects, yet producing SQL that fetches from history tables
|
|
13
|
-
# as-of-time.
|
|
14
|
-
#
|
|
5
|
+
# Replaces the left side of an Arel join (table or table alias) with a SQL
|
|
6
|
+
# literal pointing to the history virtual table at the given as_of_time,
|
|
7
|
+
# preserving any existing table alias.
|
|
15
8
|
class JoinNode < Arel::Nodes::SqlLiteral
|
|
16
|
-
attr_reader :
|
|
9
|
+
attr_reader :as_of_time
|
|
17
10
|
|
|
18
11
|
def initialize(join_node, history_model, as_of_time)
|
|
19
|
-
@name = join_node.table_name
|
|
20
|
-
@table_name = join_node.table_name
|
|
21
|
-
@table_alias = join_node.table_alias
|
|
22
|
-
|
|
23
12
|
@as_of_time = as_of_time
|
|
24
13
|
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
table_name = join_node.table_alias || join_node.name
|
|
15
|
+
virtual_table = history_model.virtual_table_at(@as_of_time, table_name: table_name)
|
|
27
16
|
|
|
28
17
|
super(virtual_table)
|
|
29
18
|
end
|
|
@@ -9,43 +9,14 @@ module ChronoModel
|
|
|
9
9
|
module Preloader
|
|
10
10
|
attr_reader :chronomodel_options
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# Overwrite the initializer to set Chronomodel +as_of_time+ and +model+
|
|
13
|
+
# options.
|
|
14
14
|
#
|
|
15
15
|
def initialize(**options)
|
|
16
16
|
@chronomodel_options = options.extract!(:as_of_time, :model)
|
|
17
17
|
options[:scope] = chronomodel_scope(options[:scope]) if options.key?(:scope)
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
super()
|
|
21
|
-
else
|
|
22
|
-
super(**options)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Patches the AR Preloader (lib/active_record/associations/preloader.rb)
|
|
27
|
-
# in order to carry around the +as_of_time+ of the original invocation.
|
|
28
|
-
#
|
|
29
|
-
# * The +records+ are the parent records where the association is defined
|
|
30
|
-
# * The +associations+ are the association names involved in preloading
|
|
31
|
-
# * The +given_preload_scope+ is the preloading scope, that is used only
|
|
32
|
-
# in the :through association and it holds the intermediate records
|
|
33
|
-
# _through_ which the final associated records are eventually fetched.
|
|
34
|
-
#
|
|
35
|
-
# As the +preload_scope+ is passed around to all the different
|
|
36
|
-
# incarnations of the preloader strategies, we are using it to pass
|
|
37
|
-
# around the +as_of_time+ of the original query invocation, so that
|
|
38
|
-
# preloaded records are preloaded honoring the +as_of_time+.
|
|
39
|
-
#
|
|
40
|
-
# The +preload_scope+ is present only in through associations, but the
|
|
41
|
-
# preloader interfaces expect it to be always defined, for consistency.
|
|
42
|
-
#
|
|
43
|
-
# For `:through` associations, the +given_preload_scope+ is already a
|
|
44
|
-
# +Relation+, that already has the +as_of_time+ getters and setters,
|
|
45
|
-
# so we use it directly.
|
|
46
|
-
#
|
|
47
|
-
def preload(records, associations, given_preload_scope = nil)
|
|
48
|
-
super(records, associations, chronomodel_scope(given_preload_scope))
|
|
19
|
+
super
|
|
49
20
|
end
|
|
50
21
|
|
|
51
22
|
private
|
|
@@ -62,6 +33,8 @@ module ChronoModel
|
|
|
62
33
|
end
|
|
63
34
|
|
|
64
35
|
module Association
|
|
36
|
+
private
|
|
37
|
+
|
|
65
38
|
# Builds the preloader scope taking into account a potential
|
|
66
39
|
# +as_of_time+ passed down the call chain starting at the
|
|
67
40
|
# end user invocation.
|
|
@@ -78,13 +51,14 @@ module ChronoModel
|
|
|
78
51
|
end
|
|
79
52
|
|
|
80
53
|
module ThroughAssociation
|
|
54
|
+
private
|
|
55
|
+
|
|
81
56
|
# Builds the preloader scope taking into account a potential
|
|
82
57
|
# +as_of_time+ passed down the call chain starting at the
|
|
83
58
|
# end user invocation.
|
|
84
59
|
#
|
|
85
60
|
def through_scope
|
|
86
61
|
scope = super
|
|
87
|
-
return unless scope # Rails 5.2 may not return a scope
|
|
88
62
|
|
|
89
63
|
if preload_scope.try(:as_of_time)
|
|
90
64
|
scope = scope.as_of(preload_scope.as_of_time)
|
|
@@ -23,10 +23,17 @@ module ChronoModel
|
|
|
23
23
|
@values == klass.unscoped.as_of(as_of_time).values
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def load
|
|
27
|
-
return super unless @_as_of_time && !loaded?
|
|
26
|
+
def load(&block)
|
|
27
|
+
return super unless @_as_of_time && (!loaded? || scheduled?)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
records = super
|
|
30
|
+
|
|
31
|
+
records.each do |record|
|
|
32
|
+
record.as_of_time!(@_as_of_time)
|
|
33
|
+
propagate_as_of_time_to_includes(record)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
self
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
def merge(*)
|
|
@@ -35,6 +42,20 @@ module ChronoModel
|
|
|
35
42
|
super.as_of_time!(@_as_of_time)
|
|
36
43
|
end
|
|
37
44
|
|
|
45
|
+
def find_nth(*)
|
|
46
|
+
return super unless try(:history?)
|
|
47
|
+
|
|
48
|
+
with_hid_pkey { super }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def last(*)
|
|
52
|
+
return super unless try(:history?)
|
|
53
|
+
|
|
54
|
+
with_hid_pkey { super }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
38
59
|
def build_arel(*)
|
|
39
60
|
return super unless @_as_of_time
|
|
40
61
|
|
|
@@ -47,54 +68,71 @@ module ChronoModel
|
|
|
47
68
|
|
|
48
69
|
# Replaces a join with the current data with another that
|
|
49
70
|
# loads records As-Of time against the history data.
|
|
50
|
-
#
|
|
51
71
|
def chrono_join_history(join)
|
|
72
|
+
join_left = join.left
|
|
73
|
+
|
|
52
74
|
# This case happens with nested includes, where the below
|
|
53
75
|
# code has already replaced the join.left with a JoinNode.
|
|
54
|
-
|
|
55
|
-
return if join.left.respond_to?(:as_of_time)
|
|
56
|
-
|
|
57
|
-
model =
|
|
58
|
-
if join.left.respond_to?(:table_name)
|
|
59
|
-
ChronoModel.history_models[join.left.table_name]
|
|
60
|
-
else
|
|
61
|
-
ChronoModel.history_models[join.left]
|
|
62
|
-
end
|
|
76
|
+
return if join_left.is_a?(ChronoModel::Patches::JoinNode)
|
|
63
77
|
|
|
78
|
+
model = ChronoModel.history_models[join_left.name] if join_left.respond_to?(:name)
|
|
64
79
|
return unless model
|
|
65
80
|
|
|
66
81
|
join.left = ChronoModel::Patches::JoinNode.new(
|
|
67
|
-
|
|
82
|
+
join_left, model.history, @_as_of_time
|
|
68
83
|
)
|
|
69
84
|
end
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
# Pass the current model to define Relation
|
|
73
|
-
#
|
|
74
|
-
def build_preloader
|
|
75
|
-
ActiveRecord::Associations::Preloader.new(
|
|
76
|
-
model: model, as_of_time: as_of_time
|
|
77
|
-
)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def find_nth(*)
|
|
86
|
+
def ordered_relation
|
|
81
87
|
return super unless try(:history?)
|
|
82
88
|
|
|
83
89
|
with_hid_pkey { super }
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
# Propagate as_of_time to associations that were eager loaded via includes/eager_load
|
|
93
|
+
def propagate_as_of_time_to_includes(record)
|
|
94
|
+
return unless eager_loading?
|
|
88
95
|
|
|
89
|
-
|
|
96
|
+
assign_as_of_time_to_spec(record, includes_values)
|
|
90
97
|
end
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
def assign_as_of_time_to_spec(record, spec)
|
|
100
|
+
case spec
|
|
101
|
+
when Symbol, String
|
|
102
|
+
assign_as_of_time_to_association(record, spec.to_sym, nil)
|
|
103
|
+
when Array
|
|
104
|
+
spec.each { |s| assign_as_of_time_to_spec(record, s) }
|
|
105
|
+
when Hash
|
|
106
|
+
# This branch is difficult to trigger in practice due to Rails query optimization.
|
|
107
|
+
# Modern Rails versions tend to optimize eager loading in ways that make this specific
|
|
108
|
+
# code path challenging to reproduce in tests without artificial scenarios.
|
|
109
|
+
spec.each do |name, nested|
|
|
110
|
+
assign_as_of_time_to_association(record, name.to_sym, nested)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
93
114
|
|
|
94
|
-
def
|
|
95
|
-
|
|
115
|
+
def assign_as_of_time_to_association(record, name, nested)
|
|
116
|
+
reflection = record.class.reflect_on_association(name)
|
|
117
|
+
return unless reflection
|
|
96
118
|
|
|
97
|
-
|
|
119
|
+
assoc = record.association(name)
|
|
120
|
+
return unless assoc.loaded?
|
|
121
|
+
|
|
122
|
+
target = assoc.target
|
|
123
|
+
|
|
124
|
+
if target.is_a?(Array)
|
|
125
|
+
target.each { |t| t.respond_to?(:as_of_time!) && t.as_of_time!(@_as_of_time) }
|
|
126
|
+
# This nested condition is difficult to trigger in practice as it requires specific
|
|
127
|
+
# association loading scenarios with Array targets and nested specs that Rails
|
|
128
|
+
# query optimization tends to handle differently in modern versions.
|
|
129
|
+
if nested.present?
|
|
130
|
+
target.each { |t| assign_as_of_time_to_spec(t, nested) }
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
target.respond_to?(:as_of_time!) && target.as_of_time!(@_as_of_time)
|
|
134
|
+
assign_as_of_time_to_spec(target, nested) if nested.present? && target
|
|
135
|
+
end
|
|
98
136
|
end
|
|
99
137
|
end
|
|
100
138
|
end
|
data/lib/chrono_model/patches.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative 'patches/as_of_time_holder'
|
|
4
|
+
require_relative 'patches/as_of_time_relation'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
require_relative 'patches/join_node'
|
|
7
|
+
require_relative 'patches/relation'
|
|
8
|
+
require_relative 'patches/preloader'
|
|
9
|
+
require_relative 'patches/association'
|
|
10
|
+
require_relative 'patches/batches'
|
data/lib/chrono_model/railtie.rb
CHANGED
|
@@ -11,7 +11,6 @@ module ChronoModel
|
|
|
11
11
|
scope :chronological, -> { order(Arel.sql('lower(validity) ASC')) }
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# ACTIVE RECORD 7 does not call `class.find` but a new internal method called `_find_record`
|
|
15
14
|
def _find_record(options)
|
|
16
15
|
if options && options[:lock]
|
|
17
16
|
self.class.preload(strict_loaded_associations).lock(options[:lock]).find_by!(hid: hid)
|
|
@@ -33,7 +32,7 @@ module ChronoModel
|
|
|
33
32
|
#
|
|
34
33
|
def with_hid_pkey
|
|
35
34
|
old = primary_key
|
|
36
|
-
self.primary_key =
|
|
35
|
+
self.primary_key = 'hid'
|
|
37
36
|
|
|
38
37
|
yield
|
|
39
38
|
ensure
|
|
@@ -83,14 +82,18 @@ module ChronoModel
|
|
|
83
82
|
|
|
84
83
|
# Fetches history record at the given time
|
|
85
84
|
#
|
|
85
|
+
# Build on an unscoped relation to avoid leaking outer predicates
|
|
86
|
+
# (e.g., through association scopes) into the inner subquery.
|
|
87
|
+
#
|
|
88
|
+
# @see https://github.com/ifad/chronomodel/issues/295
|
|
86
89
|
def at(time)
|
|
87
|
-
time_query(:at, time).from(quoted_table_name).as_of_time!(time)
|
|
90
|
+
unscoped.time_query(:at, time).from(quoted_table_name).as_of_time!(time)
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
# Returns the history sorted by recorded_at
|
|
91
94
|
#
|
|
92
95
|
def sorted
|
|
93
|
-
all.order(Arel.sql(%(
|
|
96
|
+
all.order(Arel.sql(%(#{quoted_table_name}."recorded_at" ASC, #{quoted_table_name}."hid" ASC)))
|
|
94
97
|
end
|
|
95
98
|
|
|
96
99
|
# Fetches the given +object+ history, sorted by history record time
|
|
@@ -220,10 +223,33 @@ module ChronoModel
|
|
|
220
223
|
def valid_to
|
|
221
224
|
validity.end if validity.end.is_a?(Time)
|
|
222
225
|
end
|
|
223
|
-
alias as_of_time valid_to
|
|
224
226
|
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
+
# Computes an `as_of_time` strictly inside this record's validity period
|
|
228
|
+
# for historical queries.
|
|
229
|
+
#
|
|
230
|
+
# Ensures association queries return versions that existed during this
|
|
231
|
+
# record's validity, not ones that became valid exactly at the boundary
|
|
232
|
+
# time. When objects are updated in the same transaction, they can share
|
|
233
|
+
# the same `valid_to`, which would otherwise cause boundary
|
|
234
|
+
# mis-selection.
|
|
235
|
+
#
|
|
236
|
+
# PostgreSQL ranges are half-open `[start, end)` by default.
|
|
237
|
+
#
|
|
238
|
+
# @return [Time, nil] `valid_to - ChronoModel::VALIDITY_TSRANGE_PRECISION`
|
|
239
|
+
# when `valid_to` is a Time; otherwise returns `valid_to` unchanged
|
|
240
|
+
# (which may be `nil` for open-ended validity).
|
|
241
|
+
#
|
|
242
|
+
# @see https://github.com/ifad/chronomodel/issues/283
|
|
243
|
+
def as_of_time
|
|
244
|
+
if valid_to.is_a?(Time)
|
|
245
|
+
valid_to - ChronoModel::VALIDITY_TSRANGE_PRECISION
|
|
246
|
+
else
|
|
247
|
+
valid_to
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# `.read_attribute` uses the memoized `primary_key` if it detects
|
|
252
|
+
# that the attribute name is `id`.
|
|
227
253
|
#
|
|
228
254
|
# Since the `primary key` may have been changed to `hid` because of
|
|
229
255
|
# `.find` overload, the new behavior may break relations where `id` is
|
|
@@ -240,7 +266,7 @@ module ChronoModel
|
|
|
240
266
|
|
|
241
267
|
def with_hid_pkey(&block)
|
|
242
268
|
old_primary_key = @primary_key
|
|
243
|
-
@primary_key =
|
|
269
|
+
@primary_key = 'hid'
|
|
244
270
|
|
|
245
271
|
self.class.with_hid_pkey(&block)
|
|
246
272
|
ensure
|
|
@@ -93,9 +93,9 @@ module ChronoModel
|
|
|
93
93
|
|
|
94
94
|
def build_time_query(time, range, op = '&&')
|
|
95
95
|
if time.is_a?(Array)
|
|
96
|
-
Arel.sql %[
|
|
96
|
+
Arel.sql %[#{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
|
|
97
97
|
else
|
|
98
|
-
Arel.sql %(
|
|
98
|
+
Arel.sql %(#{time} <@ #{table_name}.#{range.name})
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
end
|
|
@@ -59,7 +59,7 @@ module ChronoModel
|
|
|
59
59
|
relation = relation.from("public.#{quoted_table_name}") unless chrono?
|
|
60
60
|
relation = relation.where(id: rid) if rid
|
|
61
61
|
|
|
62
|
-
sql =
|
|
62
|
+
sql = "SELECT ts FROM (#{relation.to_sql}) AS foo WHERE ts IS NOT NULL"
|
|
63
63
|
|
|
64
64
|
if options.key?(:before)
|
|
65
65
|
sql << " AND ts < '#{Conversions.time_to_utc_string(options[:before])}'"
|
|
@@ -72,9 +72,9 @@ module ChronoModel
|
|
|
72
72
|
if rid && !options[:with]
|
|
73
73
|
sql <<
|
|
74
74
|
if chrono?
|
|
75
|
-
%{ AND ts <@ (
|
|
75
|
+
%{ AND ts <@ (SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid})}
|
|
76
76
|
else
|
|
77
|
-
|
|
77
|
+
' AND ts < NOW()'
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
require_relative 'time_machine/time_query'
|
|
4
|
+
require_relative 'time_machine/timeline'
|
|
5
|
+
require_relative 'time_machine/history_model'
|
|
6
6
|
|
|
7
7
|
module ChronoModel
|
|
8
8
|
module TimeMachine
|
|
@@ -12,7 +12,7 @@ module ChronoModel
|
|
|
12
12
|
|
|
13
13
|
included do
|
|
14
14
|
if table_exists? && !chrono?
|
|
15
|
-
logger.warn
|
|
15
|
+
logger.warn <<~MSG.squish
|
|
16
16
|
ChronoModel: #{table_name} is not a temporal table.
|
|
17
17
|
Please use `change_table :#{table_name}, temporal: true` in a migration.
|
|
18
18
|
MSG
|
|
@@ -176,7 +176,7 @@ module ChronoModel
|
|
|
176
176
|
else
|
|
177
177
|
return nil unless (ts = pred_timestamp(options))
|
|
178
178
|
|
|
179
|
-
order_clause = Arel.sql %[
|
|
179
|
+
order_clause = Arel.sql %[LOWER(#{options[:table] || self.class.quoted_table_name}."validity") DESC]
|
|
180
180
|
|
|
181
181
|
self.class.as_of(ts).order(order_clause).find(options[:id] || id)
|
|
182
182
|
end
|
|
@@ -16,12 +16,12 @@ module ChronoModel
|
|
|
16
16
|
raise 'Can amend history only with UTC timestamps'
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
connection.execute
|
|
19
|
+
connection.execute <<~SQL.squish
|
|
20
20
|
UPDATE #{quoted_table_name}
|
|
21
|
-
SET "validity"
|
|
21
|
+
SET "validity" = tsrange(#{connection.quote(from)}, #{connection.quote(to)}),
|
|
22
22
|
"recorded_at" = #{connection.quote(from)}
|
|
23
23
|
WHERE "hid" = #{hid.to_i}
|
|
24
|
-
|
|
24
|
+
SQL
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
data/lib/chrono_model/version.rb
CHANGED
data/lib/chrono_model.rb
CHANGED
|
@@ -2,21 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require 'active_record'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
require_relative 'chrono_model/chrono'
|
|
6
|
+
require_relative 'chrono_model/conversions'
|
|
7
|
+
require_relative 'chrono_model/patches'
|
|
8
|
+
require_relative 'chrono_model/adapter'
|
|
9
|
+
require_relative 'chrono_model/time_machine'
|
|
10
|
+
require_relative 'chrono_model/time_gate'
|
|
11
|
+
require_relative 'chrono_model/version'
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
require_relative 'chrono_model/railtie' if defined?(Rails::Railtie)
|
|
14
|
+
require_relative 'chrono_model/db_console' if defined?(Rails::DBConsole) && Rails.version < '7.1'
|
|
15
15
|
|
|
16
16
|
module ChronoModel
|
|
17
17
|
class Error < ActiveRecord::ActiveRecordError # :nodoc:
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# ChronoModel uses default timestamp precision (p=6) for tsrange columns.
|
|
21
|
+
# PostgreSQL timestamp precision can range from 0 to 6 fractional digits,
|
|
22
|
+
# where 6 provides microsecond resolution (1 microsecond = 10^-6 seconds).
|
|
23
|
+
VALIDITY_TSRANGE_PRECISION = Rational(1, 10**6)
|
|
24
|
+
|
|
20
25
|
# Performs structure upgrade.
|
|
21
26
|
#
|
|
22
27
|
def self.upgrade!
|
metadata
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chrono_model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcello Barnaba
|
|
8
8
|
- Peter Joseph Brindisi
|
|
9
|
-
autorequire:
|
|
10
9
|
bindir: bin
|
|
11
10
|
cert_chain: []
|
|
12
|
-
date:
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
12
|
dependencies:
|
|
14
13
|
- !ruby/object:Gem::Dependency
|
|
15
14
|
name: activerecord
|
|
@@ -101,7 +100,6 @@ metadata:
|
|
|
101
100
|
homepage_uri: https://github.com/ifad/chronomodel
|
|
102
101
|
source_code_uri: https://github.com/ifad/chronomodel
|
|
103
102
|
rubygems_mfa_required: 'true'
|
|
104
|
-
post_install_message:
|
|
105
103
|
rdoc_options: []
|
|
106
104
|
require_paths:
|
|
107
105
|
- lib
|
|
@@ -116,8 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
116
114
|
- !ruby/object:Gem::Version
|
|
117
115
|
version: '0'
|
|
118
116
|
requirements: []
|
|
119
|
-
rubygems_version:
|
|
120
|
-
signing_key:
|
|
117
|
+
rubygems_version: 4.0.3
|
|
121
118
|
specification_version: 4
|
|
122
119
|
summary: Temporal extensions (SCD Type II) for Active Record
|
|
123
120
|
test_files: []
|