chrono_model 3.0.1 → 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 +104 -41
- 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 +7 -8
- data/lib/chrono_model/adapter.rb +9 -16
- data/lib/chrono_model/conversions.rb +0 -15
- 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 +45 -19
- data/lib/chrono_model/time_machine/time_query.rb +4 -3
- data/lib/chrono_model/time_machine/timeline.rb +4 -6
- 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 -7
- data/lib/chrono_model/adapter/tsrange.rb +0 -72
|
@@ -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
|
|
@@ -107,7 +110,7 @@ module ChronoModel
|
|
|
107
110
|
# name has the "::History" suffix but that is never going to be
|
|
108
111
|
# present in the data.
|
|
109
112
|
#
|
|
110
|
-
# As such it is
|
|
113
|
+
# As such it is overridden here to return the same contents that
|
|
111
114
|
# the parent would have returned.
|
|
112
115
|
delegate :sti_name, to: :superclass
|
|
113
116
|
|
|
@@ -132,7 +135,7 @@ module ChronoModel
|
|
|
132
135
|
end
|
|
133
136
|
|
|
134
137
|
# The history id is `hid`, but this cannot set as primary key
|
|
135
|
-
# or temporal
|
|
138
|
+
# or temporal associations will break. Solutions are welcome.
|
|
136
139
|
def id
|
|
137
140
|
hid
|
|
138
141
|
end
|
|
@@ -204,26 +207,49 @@ module ChronoModel
|
|
|
204
207
|
self.class.superclass.find(rid)
|
|
205
208
|
end
|
|
206
209
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
# Return `nil` instead of -Infinity/Infinity to preserve current
|
|
211
|
+
# Chronomodel behaviour and avoid failures with Rails 7.0 and
|
|
212
|
+
# unbounded time ranges
|
|
213
|
+
#
|
|
214
|
+
# Check if `begin` and `end` are `Time` because validity is a `tsrange`
|
|
215
|
+
# column, so it is either `Time`, `nil`, and in some cases Infinity.
|
|
216
|
+
#
|
|
217
|
+
# Ref: rails/rails#45099
|
|
218
|
+
# TODO: consider removing when Rails 7.0 support will be dropped
|
|
212
219
|
def valid_from
|
|
213
|
-
validity.
|
|
220
|
+
validity.begin if validity.begin.is_a?(Time)
|
|
214
221
|
end
|
|
215
222
|
|
|
216
223
|
def valid_to
|
|
217
|
-
validity.
|
|
224
|
+
validity.end if validity.end.is_a?(Time)
|
|
218
225
|
end
|
|
219
|
-
alias as_of_time valid_to
|
|
220
226
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
249
|
end
|
|
224
250
|
|
|
225
|
-
#
|
|
226
|
-
#
|
|
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
|
|
@@ -46,7 +46,8 @@ module ChronoModel
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def time_for_time_query(t, column)
|
|
49
|
-
|
|
49
|
+
case t
|
|
50
|
+
when :now, :today
|
|
50
51
|
now_for_column(column)
|
|
51
52
|
else
|
|
52
53
|
quoted_t = connection.quote(connection.quoted_date(t))
|
|
@@ -92,9 +93,9 @@ module ChronoModel
|
|
|
92
93
|
|
|
93
94
|
def build_time_query(time, range, op = '&&')
|
|
94
95
|
if time.is_a?(Array)
|
|
95
|
-
Arel.sql %[
|
|
96
|
+
Arel.sql %[#{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
|
|
96
97
|
else
|
|
97
|
-
Arel.sql %(
|
|
98
|
+
Arel.sql %(#{time} <@ #{table_name}.#{range.name})
|
|
98
99
|
end
|
|
99
100
|
end
|
|
100
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,18 +72,16 @@ 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
|
|
|
81
81
|
sql << " LIMIT #{options[:limit].to_i}" if options.key?(:limit)
|
|
82
82
|
|
|
83
83
|
connection.on_schema(Adapter::HISTORY_SCHEMA) do
|
|
84
|
-
connection.select_values(sql, "#{name} periods")
|
|
85
|
-
Conversions.string_to_utc_time ts
|
|
86
|
-
end
|
|
84
|
+
connection.select_values(sql, "#{name} periods")
|
|
87
85
|
end
|
|
88
86
|
end
|
|
89
87
|
|
|
@@ -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
|
|
@@ -72,7 +71,6 @@ files:
|
|
|
72
71
|
- lib/chrono_model/adapter/indexes.rb
|
|
73
72
|
- lib/chrono_model/adapter/migrations.rb
|
|
74
73
|
- lib/chrono_model/adapter/migrations_modules/stable.rb
|
|
75
|
-
- lib/chrono_model/adapter/tsrange.rb
|
|
76
74
|
- lib/chrono_model/adapter/upgrade.rb
|
|
77
75
|
- lib/chrono_model/chrono.rb
|
|
78
76
|
- lib/chrono_model/conversions.rb
|
|
@@ -102,7 +100,6 @@ metadata:
|
|
|
102
100
|
homepage_uri: https://github.com/ifad/chronomodel
|
|
103
101
|
source_code_uri: https://github.com/ifad/chronomodel
|
|
104
102
|
rubygems_mfa_required: 'true'
|
|
105
|
-
post_install_message:
|
|
106
103
|
rdoc_options: []
|
|
107
104
|
require_paths:
|
|
108
105
|
- lib
|
|
@@ -117,8 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
117
114
|
- !ruby/object:Gem::Version
|
|
118
115
|
version: '0'
|
|
119
116
|
requirements: []
|
|
120
|
-
rubygems_version:
|
|
121
|
-
signing_key:
|
|
117
|
+
rubygems_version: 4.0.3
|
|
122
118
|
specification_version: 4
|
|
123
119
|
summary: Temporal extensions (SCD Type II) for Active Record
|
|
124
120
|
test_files: []
|