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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8e1a4cdc29f1fa0435f82c9277674a7085e6caf445ea45673995864038d8a1b
4
- data.tar.gz: 60df7e87f659a392cf22e8a754b608d7d1fc030d4f63a30dde4af1644ab7917d
3
+ metadata.gz: 6465bfc1eca17fb499f1826a7b938969bc7306b388ac93bce746a09c75a2c1a4
4
+ data.tar.gz: 6958a3018c4a29dbd4b81a56c60bc02d4fc8696780b62fe1ec8778725152e12d
5
5
  SHA512:
6
- metadata.gz: 22333be09e65a5347f3413976eea41581b76c7ad94dc138924cd7e5e8f6cb36cd572567f6b195c896a14262d96a3623f7b49116ed2856dcf58441ad4f43ceff2
7
- data.tar.gz: 9d96851d9dc3947e485a1c09c89eb06c0e5b07a0ddf9c43e75bdd1c3bb13ca4df599e7f4e8050268ba30c00de7878d1a6c496db1f70b2e3e9c76f0bb10d3d63c
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 *old_style* `up` and
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'chrono_model'
3
+ require_relative '../../chrono_model'
4
4
 
5
5
  module ActiveRecord
6
6
  # TODO: Remove when dropping Rails < 7.2 compatibility
@@ -32,7 +32,7 @@ module ActiveRecord
32
32
  args = ['-c', '-f', target.to_s]
33
33
  args << chronomodel_configuration[:database]
34
34
 
35
- run_cmd 'pg_dump', args, 'dumping data'
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
- run_cmd 'psql', args, 'loading data'
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 <<-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} )
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} ( #{pk}, #{fields} )
89
- VALUES ( NEW.#{pk}, #{values} );
88
+ #{insert_sequence_sql(pk, current)} INTO #{current} (#{pk}, #{fields})
89
+ VALUES (NEW.#{pk}, #{values});
90
90
 
91
- INSERT INTO #{history} ( #{pk}, #{fields}, validity )
92
- VALUES ( NEW.#{pk}, #{values}, tsrange(timezone('UTC', now()), NULL) );
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 ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
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 ( #{fields} ) = ( #{values} ) WHERE hid = _hid;
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} ( #{pk}, #{fields}, validity )
169
- VALUES ( OLD.#{pk}, #{values}, tsrange(_now, NULL) );
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 ( #{fields} ) = ( #{values} ) WHERE #{pk} = OLD.#{pk};
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 <<-SQL.squish
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} ( lower(#{range}) )"
35
- execute "CREATE INDEX #{upper_idx} ON #{table} ( upper(#{range}) )"
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 <<-SQL.squish
54
+ execute <<~SQL.squish
57
55
  ALTER TABLE #{table} ADD CONSTRAINT #{name}
58
- EXCLUDE USING gist ( #{id} WITH =, #{range} WITH && )
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 <<-SQL.squish
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} ( #{p_pkey} )"
85
- execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
86
- execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
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
- CREATE INDEX #{index.name} ON #{table_name}
149
- USING #{index.using} ( #{columns} )
150
- ], 'Copy index from temporal to history'
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
- 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
- ]
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(table_name, column_name, **options) }
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 <<-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
- );
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
@@ -2,12 +2,12 @@
2
2
 
3
3
  require 'active_record/connection_adapters/postgresql_adapter'
4
4
 
5
- require 'chrono_model/adapter/migrations'
6
- require 'chrono_model/adapter/migrations_modules/stable'
5
+ require_relative 'adapter/migrations'
6
+ require_relative 'adapter/migrations_modules/stable'
7
7
 
8
- require 'chrono_model/adapter/ddl'
9
- require 'chrono_model/adapter/indexes'
10
- require 'chrono_model/adapter/upgrade'
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
- # NOTE: This method is dynamically defined, see the source.
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(table_name) }
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 %( COMMENT ON VIEW #{view_name} IS #{quote(comment)} )
168
+ execute "COMMENT ON VIEW #{view_name} IS #{quote(comment)}"
174
169
  end
175
170
 
176
171
  def valid_table_definition_options
@@ -4,8 +4,6 @@ module ChronoModel
4
4
  module Conversions
5
5
  module_function
6
6
 
7
- ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
8
-
9
7
  def time_to_utc_string(time)
10
8
  time.to_fs(:db) << '.' << format('%06d', time.usec)
11
9
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'chrono_model/patches/db_console'
3
+ require_relative 'patches/db_console'
4
4
 
5
5
  Rails::DBConsole.prepend ChronoModel::Patches::DBConsole::DbConfig
@@ -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 in_batches(**)
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
- # This class supports the AR 5.0 code that expects to receive an
6
- # Arel::Table as the left join node. We need to replace the node
7
- # with a virtual table that fetches from the history at a given
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 :name, :table_name, :table_alias, :as_of_time
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
- virtual_table = history_model
26
- .virtual_table_at(@as_of_time, table_name: @table_alias || @table_name)
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
- # We overwrite the initializer in order to pass the +as_of_time+
13
- # parameter above in the build_preloader
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
- if options.empty?
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
- super.each { |record| record.as_of_time!(@_as_of_time) }
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
- join.left, model.history, @_as_of_time
82
+ join_left, model.history, @_as_of_time
68
83
  )
69
84
  end
70
85
 
71
- # Build a preloader at the +as_of_time+ of this relation.
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
- def last(*)
87
- return super unless try(:history?)
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
- with_hid_pkey { super }
96
+ assign_as_of_time_to_spec(record, includes_values)
90
97
  end
91
98
 
92
- private
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 ordered_relation
95
- return super unless try(:history?)
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
- with_hid_pkey { super }
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
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'chrono_model/patches/as_of_time_holder'
4
- require 'chrono_model/patches/as_of_time_relation'
3
+ require_relative 'patches/as_of_time_holder'
4
+ require_relative 'patches/as_of_time_relation'
5
5
 
6
- require 'chrono_model/patches/join_node'
7
- require 'chrono_model/patches/relation'
8
- require 'chrono_model/patches/preloader'
9
- require 'chrono_model/patches/association'
10
- require 'chrono_model/patches/batches'
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'
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record/tasks/chronomodel_database_tasks'
3
+ require_relative '../active_record/tasks/chronomodel_database_tasks'
4
4
 
5
5
  module ChronoModel
6
6
  class Railtie < ::Rails::Railtie
@@ -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 = :hid
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(%( #{quoted_table_name}."recorded_at" ASC, #{quoted_table_name}."hid" ASC )))
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
- # Starting from Rails 6.0, `.read_attribute` will use the memoized
226
- # `primary_key` if it detects that the attribute name is `id`.
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 = :hid
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 %[ #{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
96
+ Arel.sql %[#{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
97
97
  else
98
- Arel.sql %( #{time} <@ #{table_name}.#{range.name} )
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 = +"SELECT ts FROM ( #{relation.to_sql} ) AS foo WHERE ts IS NOT NULL"
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 <@ ( SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid} ) }
75
+ %{ AND ts <@ (SELECT tsrange(min(lower(validity)), max(upper(validity)), '[]') FROM #{quoted_table_name} WHERE id = #{rid})}
76
76
  else
77
- %[ AND ts < NOW() ]
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
- require 'chrono_model/time_machine/time_query'
4
- require 'chrono_model/time_machine/timeline'
5
- require 'chrono_model/time_machine/history_model'
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 <<-MSG.squish
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 %[ LOWER(#{options[:table] || self.class.quoted_table_name}."validity") DESC ]
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" = tsrange(#{connection.quote(from)}, #{connection.quote(to)}),
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoModel
4
- VERSION = '4.0.0'
4
+ VERSION = '5.0.0'
5
5
  end
data/lib/chrono_model.rb CHANGED
@@ -2,21 +2,26 @@
2
2
 
3
3
  require 'active_record'
4
4
 
5
- require 'chrono_model/chrono'
6
- require 'chrono_model/conversions'
7
- require 'chrono_model/patches'
8
- require 'chrono_model/adapter'
9
- require 'chrono_model/time_machine'
10
- require 'chrono_model/time_gate'
11
- require 'chrono_model/version'
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
- require 'chrono_model/railtie' if defined?(Rails::Railtie)
14
- require 'chrono_model/db_console' if defined?(Rails::DBConsole) && Rails.version < '7.1'
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.0.0
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: 2024-05-12 00:00:00.000000000 Z
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: 3.5.9
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: []