activerecord-temporal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +12 -0
- data/lib/activerecord/temporal/application_versioning.rb +77 -0
- data/lib/activerecord/temporal/as_of_query/association_macros.rb +41 -0
- data/lib/activerecord/temporal/as_of_query/association_scope.rb +54 -0
- data/lib/activerecord/temporal/as_of_query/association_walker.rb +40 -0
- data/lib/activerecord/temporal/as_of_query/query_methods.rb +24 -0
- data/lib/activerecord/temporal/as_of_query/scope_registry.rb +38 -0
- data/lib/activerecord/temporal/as_of_query/time_dimensions.rb +69 -0
- data/lib/activerecord/temporal/as_of_query.rb +109 -0
- data/lib/activerecord/temporal/patches/association_reflection.rb +19 -0
- data/lib/activerecord/temporal/patches/join_dependency.rb +53 -0
- data/lib/activerecord/temporal/patches/merger.rb +11 -0
- data/lib/activerecord/temporal/patches/relation.rb +29 -0
- data/lib/activerecord/temporal/patches/through_association.rb +11 -0
- data/lib/activerecord/temporal/system_versioning/command_recorder.rb +47 -0
- data/lib/activerecord/temporal/system_versioning/model.rb +37 -0
- data/lib/activerecord/temporal/system_versioning/namespace.rb +34 -0
- data/lib/activerecord/temporal/system_versioning/schema_creation.rb +147 -0
- data/lib/activerecord/temporal/system_versioning/schema_definitions.rb +37 -0
- data/lib/activerecord/temporal/system_versioning/schema_statements.rb +129 -0
- data/lib/activerecord/temporal/system_versioning.rb +27 -0
- data/lib/activerecord/temporal/version.rb +3 -0
- data/lib/activerecord/temporal.rb +63 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 885b7f7b89f8bf5631d9ff433e3848104b2dd4adf00b5fb5ae192ca77fc2791c
|
|
4
|
+
data.tar.gz: 36d7fc15adf37f0209b564a78d601ad4b2615e16227caf2a66ae21a71e4109a4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8ee064c4813043534429ec6601b44966db678ddc616063c504f5b0b1e468edc29cb6485cb951c838f7a4d409ac620f132ed5f660bf69d310c8fa33d4d00cad11
|
|
7
|
+
data.tar.gz: 1458150e9e6297b29c10b8881b9721fe566c3cb67ccbe2c977a0db2acbf520bbff391e463c7868af5b021c1962a0da09084318f734875095c2c7309edcca4c42
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Martin-Alexander
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module ApplicationVersioning
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
include AsOfQuery
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Revision
|
|
10
|
+
attr_reader :record, :time, :options
|
|
11
|
+
|
|
12
|
+
def initialize(record, time, **options)
|
|
13
|
+
@record = record
|
|
14
|
+
@time = time
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with(attributes)
|
|
19
|
+
new_revision = record.dup
|
|
20
|
+
new_revision.assign_attributes(attributes)
|
|
21
|
+
new_revision.set_time_dimension_start(time)
|
|
22
|
+
new_revision.time_scopes = record.time_scopes
|
|
23
|
+
record.set_time_dimension_end(time)
|
|
24
|
+
|
|
25
|
+
new_revision.after_initialize_revision(record)
|
|
26
|
+
|
|
27
|
+
if options[:save]
|
|
28
|
+
record.class.transaction do
|
|
29
|
+
new_revision.save if record.save
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
[new_revision, record]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def after_initialize_revision(old_revision)
|
|
38
|
+
self.version = old_revision.version + 1
|
|
39
|
+
self.id_value = old_revision.id_value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def head_revision?
|
|
43
|
+
time_dimension && !time_dimension_end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def revise
|
|
47
|
+
revise_at(Time.current)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def revise_at(time)
|
|
51
|
+
raise "not head revision" unless head_revision?
|
|
52
|
+
|
|
53
|
+
Revision.new(self, time, save: true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def revision
|
|
57
|
+
revision_at(Time.current)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def revision_at(time)
|
|
61
|
+
raise "not head revision" unless head_revision?
|
|
62
|
+
|
|
63
|
+
Revision.new(self, time, save: false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inactivate
|
|
67
|
+
inactivate_at(Time.current)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inactivate_at(time)
|
|
71
|
+
raise "not head revision" unless head_revision?
|
|
72
|
+
|
|
73
|
+
set_time_dimension_end(time)
|
|
74
|
+
save
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
module AssociationMacros
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
def has_many(name, scope = nil, **options, &extension)
|
|
8
|
+
scope = handle_temporal_scope_option(scope, options)
|
|
9
|
+
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def has_one(name, scope = nil, **options)
|
|
14
|
+
scope = handle_temporal_scope_option(scope, options)
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def belongs_to(name, scope = nil, **options)
|
|
20
|
+
scope = handle_temporal_scope_option(scope, options)
|
|
21
|
+
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def has_and_belongs_to_many(name, scope = nil, **options, &extension)
|
|
26
|
+
scope = handle_temporal_scope_option(scope, options)
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def handle_temporal_scope_option(scope, options)
|
|
34
|
+
temporal = options.extract!(:temporal)[:temporal]
|
|
35
|
+
|
|
36
|
+
temporal ? AssociationScope.build(scope) : scope
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
class AssociationScope
|
|
4
|
+
class << self
|
|
5
|
+
def build(block)
|
|
6
|
+
scope = build_scope(block || default_base_scope)
|
|
7
|
+
|
|
8
|
+
def scope.as_of_scope? = true
|
|
9
|
+
|
|
10
|
+
scope
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def build_scope(block)
|
|
16
|
+
temporal_scope = build_temporal_scope
|
|
17
|
+
|
|
18
|
+
if block.arity != 0
|
|
19
|
+
return ->(owner) do
|
|
20
|
+
base = instance_exec(owner, &block)
|
|
21
|
+
instance_exec(owner, base, &temporal_scope)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
->(owner = nil) do
|
|
26
|
+
base = instance_exec(owner, &block)
|
|
27
|
+
instance_exec(owner, base, &temporal_scope)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_temporal_scope
|
|
32
|
+
->(owner, base) do
|
|
33
|
+
time_scopes = ScopeRegistry.query_scope_for(time_dimensions)
|
|
34
|
+
owner_time_scopes = owner&.time_scopes_for(time_dimensions)
|
|
35
|
+
|
|
36
|
+
time_scopes.merge!(owner_time_scopes) if owner_time_scopes
|
|
37
|
+
|
|
38
|
+
default_time_scopes = time_dimensions.map do |dimension|
|
|
39
|
+
[dimension, Time.current]
|
|
40
|
+
end.to_h
|
|
41
|
+
|
|
42
|
+
time_scope_constraints = default_time_scopes.merge(time_scopes)
|
|
43
|
+
|
|
44
|
+
base.existed_at(time_scope_constraints).time_scope(time_scopes)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def default_base_scope
|
|
49
|
+
proc { all }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
class AssociationWalker
|
|
4
|
+
class << self
|
|
5
|
+
def each_target(parent_record, associations, &block)
|
|
6
|
+
walk_nodes(associations) do |association|
|
|
7
|
+
target = parent_record.association(association).target
|
|
8
|
+
|
|
9
|
+
next unless target
|
|
10
|
+
|
|
11
|
+
if target.is_a?(Array)
|
|
12
|
+
target.each(&block)
|
|
13
|
+
else
|
|
14
|
+
block.call(target)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def walk_nodes(node, &block)
|
|
22
|
+
case node
|
|
23
|
+
when Symbol, String
|
|
24
|
+
block.call(node)
|
|
25
|
+
when Array
|
|
26
|
+
node.each { |child| walk_nodes(child, &block) }
|
|
27
|
+
when Hash
|
|
28
|
+
# TODO: Write tests
|
|
29
|
+
|
|
30
|
+
node.each do |parent, child|
|
|
31
|
+
block.call(parent)
|
|
32
|
+
|
|
33
|
+
walk_nodes(child, &block)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
module QueryMethods
|
|
4
|
+
def time_scope(scope)
|
|
5
|
+
spawn.time_scope!(scope)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def time_scope!(scope)
|
|
9
|
+
self.time_scope_values = time_scope_values.merge(scope)
|
|
10
|
+
self
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def time_scope_values
|
|
14
|
+
@values.fetch(:time_scope, ActiveRecord::QueryMethods::FROZEN_EMPTY_HASH)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def time_scope_values=(scope)
|
|
18
|
+
assert_modifiable! # TODO: write test
|
|
19
|
+
|
|
20
|
+
@values[:time_scope] = scope
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
class ScopeRegistry
|
|
4
|
+
class << self
|
|
5
|
+
delegate :default_scopes, :query_scopes, :set_default_scopes, :query_scope_for, :with_query_scope, to: :instance
|
|
6
|
+
|
|
7
|
+
def instance
|
|
8
|
+
ActiveSupport::IsolatedExecutionState[:temporal_as_of_query_registry] ||= new
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :default_scopes, :query_scopes
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@default_scopes = {}
|
|
16
|
+
@query_scopes = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_default_scopes(default_scopes)
|
|
20
|
+
@default_scopes = default_scopes
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def query_scope_for(dimensions)
|
|
24
|
+
query_scopes.slice(*dimensions)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_query_scope(scope, &block)
|
|
28
|
+
original = @query_scopes.dup
|
|
29
|
+
|
|
30
|
+
@query_scopes = @query_scopes.merge(scope)
|
|
31
|
+
|
|
32
|
+
block.call
|
|
33
|
+
ensure
|
|
34
|
+
@query_scopes = original
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
module TimeDimensions
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
delegate :time_dimensions, :default_time_dimension, :time_dimension_column?, to: :class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def set_time_dimensions(*dimensions)
|
|
12
|
+
define_singleton_method(:time_dimensions) { dimensions }
|
|
13
|
+
define_singleton_method(:default_time_dimension) { dimensions.first }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def time_dimensions = []
|
|
17
|
+
def default_time_dimension = nil
|
|
18
|
+
|
|
19
|
+
def time_dimension_column?(time_dimension)
|
|
20
|
+
connection.column_exists?(table_name, time_dimension)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def time_dimension(dimension = nil)
|
|
25
|
+
dimension ||= default_time_dimension
|
|
26
|
+
|
|
27
|
+
if !time_dimension_column?(dimension)
|
|
28
|
+
raise ArgumentError, "no time dimension column '#{dimension}'"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
send(dimension)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def time_dimension_start(dimension = nil)
|
|
35
|
+
time_dimension(dimension)&.begin
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def time_dimension_end(dimension = nil)
|
|
39
|
+
time_dimension(dimension)&.end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_time_dimension(value, dimension = nil)
|
|
43
|
+
dimension ||= default_time_dimension
|
|
44
|
+
|
|
45
|
+
if !time_dimension_column?(dimension)
|
|
46
|
+
raise ArgumentError, "no time dimension column '#{dimension}'"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
send("#{dimension}=", value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_time_dimension_start(value, dimension = nil)
|
|
53
|
+
existing_value = time_dimension(dimension)
|
|
54
|
+
|
|
55
|
+
new_value = existing_value ? value...existing_value.end : value...nil
|
|
56
|
+
|
|
57
|
+
set_time_dimension(new_value, dimension)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def set_time_dimension_end(value, dimension = nil)
|
|
61
|
+
existing_value = time_dimension(dimension)
|
|
62
|
+
|
|
63
|
+
new_value = existing_value ? existing_value.begin...value : nil...value
|
|
64
|
+
|
|
65
|
+
set_time_dimension(new_value, dimension)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module AsOfQuery
|
|
3
|
+
class RangeError < StandardError; end
|
|
4
|
+
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def existed_at_constraint(arel_table, time, time_dimension)
|
|
9
|
+
time_as_tstz = Arel::Nodes::As.new(
|
|
10
|
+
Arel::Nodes::Quoted.new(time),
|
|
11
|
+
Arel::Nodes::SqlLiteral.new("timestamptz")
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
cast_value = Arel::Nodes::NamedFunction.new("CAST", [time_as_tstz])
|
|
15
|
+
|
|
16
|
+
arel_table[time_dimension].contains(cast_value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def temporal_association_scope(&block)
|
|
20
|
+
AssociationScope.build(block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolve_time_scopes(time_or_time_scopes)
|
|
24
|
+
return time_or_time_scopes if time_or_time_scopes.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
{default_time_dimension.to_sym => time_or_time_scopes}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
included do
|
|
31
|
+
include AssociationMacros
|
|
32
|
+
include TimeDimensions
|
|
33
|
+
|
|
34
|
+
delegate :resolve_time_scopes, to: :class
|
|
35
|
+
|
|
36
|
+
scope :as_of, ->(time) do
|
|
37
|
+
time_scopes = resolve_time_scopes(time)
|
|
38
|
+
|
|
39
|
+
existed_at(time_scopes).time_scope(time_scopes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
scope :existed_at, ->(time) do
|
|
43
|
+
time_scopes = resolve_time_scopes(time)
|
|
44
|
+
|
|
45
|
+
rel = all
|
|
46
|
+
|
|
47
|
+
time_scopes.each do |time_dimension, time|
|
|
48
|
+
next unless time_dimension_column?(time_dimension)
|
|
49
|
+
|
|
50
|
+
rel = rel.where(existed_at_constraint(table, time, time_dimension))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
rel
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def time_scopes
|
|
58
|
+
@time_scopes || {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def time_scopes=(value)
|
|
62
|
+
@time_scopes = value&.slice(*time_dimensions)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def time_scope
|
|
66
|
+
time_scopes[default_time_dimension]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def time_scopes_for(time_dimensions)
|
|
70
|
+
time_scopes.slice(*time_dimensions)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def as_of!(time)
|
|
74
|
+
time_scopes = resolve_time_scopes(time)
|
|
75
|
+
|
|
76
|
+
ensure_time_scopes_in_bounds!(time_scopes)
|
|
77
|
+
|
|
78
|
+
reload
|
|
79
|
+
|
|
80
|
+
self.time_scopes = time_scopes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def as_of(time)
|
|
84
|
+
time_scopes = resolve_time_scopes(time)
|
|
85
|
+
|
|
86
|
+
self.class.as_of(time_scopes).find_by(self.class.primary_key => [id])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize_time_scope_from_relation(relation)
|
|
90
|
+
associations = relation.includes_values | relation.eager_load_values
|
|
91
|
+
|
|
92
|
+
self.time_scopes = relation.time_scope_values
|
|
93
|
+
|
|
94
|
+
AssociationWalker.each_target(self, associations) do |target|
|
|
95
|
+
target.time_scopes = relation.time_scope_values
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def ensure_time_scopes_in_bounds!(time_scopes)
|
|
102
|
+
time_scopes.each do |dimension, time|
|
|
103
|
+
if time_dimension_column?(dimension) && !time_dimension(dimension).cover?(time)
|
|
104
|
+
raise RangeError, "#{time} is outside of '#{dimension}' range"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module Patches
|
|
3
|
+
module AssociationReflection
|
|
4
|
+
def check_eager_loadable!
|
|
5
|
+
super unless as_of_scope? && scope_requires_no_params?
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def as_of_scope?
|
|
11
|
+
scope.respond_to?(:as_of_scope?) && scope.as_of_scope?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scope_requires_no_params?
|
|
15
|
+
scope.arity == 0 || scope.arity == -1
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module Patches
|
|
3
|
+
module JoinDependency
|
|
4
|
+
def instantiate(result_set, strict_loading_value, &block)
|
|
5
|
+
primary_key = Array(join_root.primary_key).map { |column| aliases.column_alias(join_root, column) }
|
|
6
|
+
|
|
7
|
+
seen = Hash.new { |i, parent|
|
|
8
|
+
i[parent] = Hash.new { |j, child_class|
|
|
9
|
+
j[child_class] = {}
|
|
10
|
+
}
|
|
11
|
+
}.compare_by_identity
|
|
12
|
+
|
|
13
|
+
model_cache = Hash.new { |h, klass| h[klass] = {} }
|
|
14
|
+
parents = model_cache[join_root]
|
|
15
|
+
|
|
16
|
+
column_aliases = aliases.column_aliases(join_root)
|
|
17
|
+
column_names = []
|
|
18
|
+
|
|
19
|
+
result_set.columns.each do |name|
|
|
20
|
+
column_names << name unless /\At\d+_r\d+\z/.match?(name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if column_names.empty?
|
|
24
|
+
column_types = {}
|
|
25
|
+
else
|
|
26
|
+
column_types = result_set.column_types
|
|
27
|
+
unless column_types.empty?
|
|
28
|
+
attribute_types = join_root.attribute_types
|
|
29
|
+
column_types = column_types.slice(*column_names).delete_if { |k, _| attribute_types.key?(k) }
|
|
30
|
+
end
|
|
31
|
+
column_aliases += column_names.map! { |name| Aliases::Column.new(name, name) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
message_bus = ActiveSupport::Notifications.instrumenter
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
record_count: result_set.length,
|
|
38
|
+
class_name: join_root.base_klass.name
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
message_bus.instrument("instantiation.active_record", payload) do
|
|
42
|
+
result_set.each { |row_hash|
|
|
43
|
+
parent_key = primary_key.empty? ? row_hash : row_hash.values_at(*primary_key)
|
|
44
|
+
parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, column_types, &block)
|
|
45
|
+
construct(parent, join_root, row_hash, seen, model_cache, strict_loading_value)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parents.values
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module Patches
|
|
3
|
+
module Relation
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
if ActiveRecord.version > Gem::Version.new("8.0.4")
|
|
7
|
+
def build_arel(connection)
|
|
8
|
+
AsOfQuery::ScopeRegistry.with_query_scope(time_scope_values) { super }
|
|
9
|
+
end
|
|
10
|
+
else
|
|
11
|
+
def build_arel(connection, aliases = nil)
|
|
12
|
+
AsOfQuery::ScopeRegistry.with_query_scope(time_scope_values) { super }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def instantiate_records(rows, &block)
|
|
17
|
+
return super if time_scope_values.empty?
|
|
18
|
+
|
|
19
|
+
records = super
|
|
20
|
+
|
|
21
|
+
records.each do |record|
|
|
22
|
+
record.initialize_time_scope_from_relation(self) if record.is_a?(AsOfQuery)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
records
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
module CommandRecorder
|
|
4
|
+
[
|
|
5
|
+
:create_versioning_hook,
|
|
6
|
+
:drop_versioning_hook,
|
|
7
|
+
:change_versioning_hook
|
|
8
|
+
].each do |method|
|
|
9
|
+
class_eval <<-EOV, __FILE__, __LINE__ + 1
|
|
10
|
+
def #{method}(*args)
|
|
11
|
+
record(:#{method}, args)
|
|
12
|
+
end
|
|
13
|
+
EOV
|
|
14
|
+
|
|
15
|
+
ruby2_keywords(method)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def invert_drop_versioning_hook(args)
|
|
19
|
+
_, _, columns = args
|
|
20
|
+
|
|
21
|
+
if columns.nil?
|
|
22
|
+
raise ActiveRecord::IrreversibleMigration, "drop_versioning_hook is only reversible if given :columns option."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[:create_versioning_hook, args]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def invert_create_versioning_hook(args)
|
|
29
|
+
[:drop_versioning_hook, args]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def invert_change_versioning_hook(args)
|
|
33
|
+
source_table, history_table, options = args
|
|
34
|
+
|
|
35
|
+
[
|
|
36
|
+
:change_versioning_hook,
|
|
37
|
+
[
|
|
38
|
+
source_table,
|
|
39
|
+
history_table,
|
|
40
|
+
add_columns: options[:remove_columns],
|
|
41
|
+
remove_columns: options[:add_columns]
|
|
42
|
+
]
|
|
43
|
+
]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
module Model
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
include AsOfQuery
|
|
8
|
+
|
|
9
|
+
set_time_dimensions :system_period
|
|
10
|
+
|
|
11
|
+
reflect_on_all_associations.each do |reflection|
|
|
12
|
+
scope = temporal_association_scope(&reflection.scope)
|
|
13
|
+
|
|
14
|
+
send(reflection.macro, reflection.name, scope, **reflection.options)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
def polymorphic_class_for(name)
|
|
20
|
+
super.version_model
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sti_name
|
|
24
|
+
superclass.sti_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_sti_class(type_name)
|
|
28
|
+
superclass.send(:find_sti_class, type_name).version_model
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def finder_needs_type_condition?
|
|
32
|
+
superclass.finder_needs_type_condition?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
module Namespace
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
def const_missing(name)
|
|
8
|
+
model = name.to_s.constantize
|
|
9
|
+
rescue NameError
|
|
10
|
+
super
|
|
11
|
+
else
|
|
12
|
+
unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
13
|
+
raise NameError, "#{model} is not a descendent of ActiveRecord::Base"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
version_model = if (history_table = model.history_table)
|
|
17
|
+
Class.new(model) do
|
|
18
|
+
self.table_name = history_table
|
|
19
|
+
self.primary_key = model.primary_key_from_db + [:system_period]
|
|
20
|
+
|
|
21
|
+
include Model
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
Class.new(model) do
|
|
25
|
+
include Model
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
const_set(name, version_model)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require "active_support/core_ext/hash/deep_transform_values"
|
|
2
|
+
|
|
3
|
+
module ActiveRecord::Temporal
|
|
4
|
+
module SystemVersioning
|
|
5
|
+
class SchemaCreation
|
|
6
|
+
def initialize(conn)
|
|
7
|
+
@conn = conn
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def accept(o)
|
|
11
|
+
m = "visit_#{o.class.name.split("::").last}"
|
|
12
|
+
send m, o
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
delegate :quote_table_name, :quote_column_name, :quote_string, :versioning_function_name, to: :@conn, private: true
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def visit_VersioningHookDefinition(o)
|
|
20
|
+
[o.insert_hook, o.update_hook, o.delete_hook].map { |t| accept(t) }.join(" ")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_InsertHookDefinition(o)
|
|
24
|
+
column_names = o.columns.map { |c| quote_column_name(c) }
|
|
25
|
+
fields = column_names.join(", ")
|
|
26
|
+
values = column_names.map { |c| "NEW.#{c}" }.join(", ")
|
|
27
|
+
function_name = versioning_function_name(o.source_table, :insert)
|
|
28
|
+
|
|
29
|
+
metadata = {
|
|
30
|
+
verb: :insert,
|
|
31
|
+
source_table: o.source_table,
|
|
32
|
+
history_table: o.history_table,
|
|
33
|
+
columns: o.columns
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
<<~SQL
|
|
37
|
+
CREATE FUNCTION #{function_name}() RETURNS TRIGGER AS $$
|
|
38
|
+
BEGIN
|
|
39
|
+
INSERT INTO #{quote_table_name(o.history_table)} (#{fields}, system_period)
|
|
40
|
+
VALUES (#{values}, tstzrange(NOW(), 'infinity'));
|
|
41
|
+
|
|
42
|
+
RETURN NULL;
|
|
43
|
+
END;
|
|
44
|
+
$$ LANGUAGE plpgsql;
|
|
45
|
+
|
|
46
|
+
CREATE TRIGGER versioning_insert_trigger AFTER INSERT ON #{quote_table_name(o.source_table)}
|
|
47
|
+
FOR EACH ROW EXECUTE PROCEDURE #{function_name}();
|
|
48
|
+
|
|
49
|
+
#{create_metadata_comment(function_name, metadata)}
|
|
50
|
+
SQL
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visit_UpdateHookDefinition(o)
|
|
54
|
+
column_names = o.columns.map { |c| quote_column_name(c) }
|
|
55
|
+
primary_key_quoted = o.primary_key.map { |c| quote_column_name(c) }
|
|
56
|
+
fields = column_names.join(", ")
|
|
57
|
+
values = column_names.map { |c| "NEW.#{c}" }.join(", ")
|
|
58
|
+
update_pk_predicates = primary_key_quoted.map { |c| "#{c} = OLD.#{c}" }.join(" AND ")
|
|
59
|
+
on_conflict_constraint = (primary_key_quoted + [:system_period]).join(", ")
|
|
60
|
+
on_conflict_sets = column_names.map { |c| "#{c} = EXCLUDED.#{c}" }.join(", ")
|
|
61
|
+
function_name = versioning_function_name(o.source_table, :update)
|
|
62
|
+
metadata = {
|
|
63
|
+
verb: :update,
|
|
64
|
+
source_table: o.source_table,
|
|
65
|
+
history_table: o.history_table,
|
|
66
|
+
columns: o.columns,
|
|
67
|
+
primary_key: o.primary_key
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
<<~SQL
|
|
71
|
+
CREATE FUNCTION #{function_name}() RETURNS trigger AS $$
|
|
72
|
+
BEGIN
|
|
73
|
+
IF OLD IS NOT DISTINCT FROM NEW THEN
|
|
74
|
+
RETURN NULL;
|
|
75
|
+
END IF;
|
|
76
|
+
|
|
77
|
+
UPDATE #{quote_table_name(o.history_table)}
|
|
78
|
+
SET system_period = tstzrange(lower(system_period), NOW())
|
|
79
|
+
WHERE #{update_pk_predicates} AND upper(system_period) = 'infinity' AND lower(system_period) < NOW();
|
|
80
|
+
|
|
81
|
+
INSERT INTO #{quote_table_name(o.history_table)} (#{fields}, system_period)
|
|
82
|
+
VALUES (#{values}, tstzrange(NOW(), 'infinity'))
|
|
83
|
+
ON CONFLICT (#{on_conflict_constraint}) DO UPDATE SET #{on_conflict_sets};
|
|
84
|
+
|
|
85
|
+
RETURN NULL;
|
|
86
|
+
END;
|
|
87
|
+
$$ LANGUAGE plpgsql;
|
|
88
|
+
|
|
89
|
+
CREATE TRIGGER versioning_update_trigger AFTER UPDATE ON #{quote_table_name(o.source_table)}
|
|
90
|
+
FOR EACH ROW EXECUTE PROCEDURE #{function_name}();
|
|
91
|
+
|
|
92
|
+
#{create_metadata_comment(function_name, metadata)}
|
|
93
|
+
SQL
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit_DeleteHookDefinition(o)
|
|
97
|
+
primary_key_quoted = o.primary_key.map { |c| quote_column_name(c) }
|
|
98
|
+
function_name = versioning_function_name(o.source_table, :delete)
|
|
99
|
+
pk_predicates = primary_key_quoted.map { |c| "#{c} = OLD.#{c}" }.join(" AND ")
|
|
100
|
+
metadata = {
|
|
101
|
+
verb: :delete,
|
|
102
|
+
source_table: o.source_table,
|
|
103
|
+
history_table: o.history_table,
|
|
104
|
+
primary_key: o.primary_key
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
<<~SQL
|
|
108
|
+
CREATE FUNCTION #{function_name}() RETURNS TRIGGER AS $$
|
|
109
|
+
BEGIN
|
|
110
|
+
DELETE FROM #{quote_table_name(o.history_table)}
|
|
111
|
+
WHERE #{pk_predicates} AND system_period = tstzrange(NOW(), 'infinity');
|
|
112
|
+
|
|
113
|
+
UPDATE #{quote_table_name(o.history_table)}
|
|
114
|
+
SET system_period = tstzrange(lower(system_period), NOW())
|
|
115
|
+
WHERE #{pk_predicates} AND upper(system_period) = 'infinity';
|
|
116
|
+
|
|
117
|
+
RETURN NULL;
|
|
118
|
+
END;
|
|
119
|
+
$$ LANGUAGE plpgsql;
|
|
120
|
+
|
|
121
|
+
CREATE TRIGGER versioning_delete_trigger AFTER DELETE ON #{quote_table_name(o.source_table)}
|
|
122
|
+
FOR EACH ROW EXECUTE PROCEDURE #{function_name}();
|
|
123
|
+
|
|
124
|
+
#{create_metadata_comment(function_name, metadata)}
|
|
125
|
+
SQL
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def create_metadata_comment(function_name, metadata)
|
|
131
|
+
quoted_metadata = metadata.deep_transform_values do |v|
|
|
132
|
+
if v.is_a?(Array)
|
|
133
|
+
v.map { quote_string(_1.to_s) }
|
|
134
|
+
else
|
|
135
|
+
quote_string(v.to_s)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
json = JSON.generate(quoted_metadata)
|
|
140
|
+
|
|
141
|
+
<<~SQL
|
|
142
|
+
COMMENT ON FUNCTION #{function_name}() IS '#{json}';
|
|
143
|
+
SQL
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
class VersioningHookDefinition
|
|
4
|
+
attr_accessor :source_table, :history_table, :columns, :primary_key
|
|
5
|
+
|
|
6
|
+
def initialize(
|
|
7
|
+
source_table,
|
|
8
|
+
history_table,
|
|
9
|
+
columns:,
|
|
10
|
+
primary_key:
|
|
11
|
+
)
|
|
12
|
+
@source_table = source_table
|
|
13
|
+
@history_table = history_table
|
|
14
|
+
@columns = columns
|
|
15
|
+
@primary_key = primary_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def insert_hook
|
|
19
|
+
InsertHookDefinition.new(@source_table, @history_table, @columns)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update_hook
|
|
23
|
+
UpdateHookDefinition.new(@source_table, @history_table, @columns, @primary_key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def delete_hook
|
|
27
|
+
DeleteHookDefinition.new(@source_table, @history_table, @primary_key)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
InsertHookDefinition = Struct.new(:source_table, :history_table, :columns)
|
|
32
|
+
|
|
33
|
+
UpdateHookDefinition = Struct.new(:source_table, :history_table, :columns, :primary_key)
|
|
34
|
+
|
|
35
|
+
DeleteHookDefinition = Struct.new(:source_table, :history_table, :primary_key)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
module SchemaStatements
|
|
4
|
+
def create_versioning_hook(source_table, history_table, **options)
|
|
5
|
+
columns = options.fetch(:columns)&.map(&:to_s)
|
|
6
|
+
primary_key = Array(options.fetch(:primary_key, :id))
|
|
7
|
+
|
|
8
|
+
ensure_table_exists!(source_table)
|
|
9
|
+
ensure_table_exists!(history_table)
|
|
10
|
+
ensure_columns_match!(source_table, history_table, columns)
|
|
11
|
+
ensure_columns_exists!(source_table, primary_key)
|
|
12
|
+
|
|
13
|
+
schema_creation = SchemaCreation.new(self)
|
|
14
|
+
|
|
15
|
+
hook_definition = VersioningHookDefinition.new(
|
|
16
|
+
source_table,
|
|
17
|
+
history_table,
|
|
18
|
+
columns: columns,
|
|
19
|
+
primary_key: primary_key
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
execute schema_creation.accept(hook_definition)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def drop_versioning_hook(source_table, history_table, columns: nil)
|
|
26
|
+
%i[insert update delete].each do |verb|
|
|
27
|
+
function_name = versioning_function_name(source_table, verb)
|
|
28
|
+
|
|
29
|
+
execute "DROP FUNCTION #{function_name}() CASCADE"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def versioning_hook(source_table)
|
|
34
|
+
update_function_name = versioning_function_name(source_table, :update)
|
|
35
|
+
|
|
36
|
+
row = execute(<<~SQL.squish).first
|
|
37
|
+
SELECT
|
|
38
|
+
pg_proc.proname as function_name,
|
|
39
|
+
obj_description(pg_proc.oid, 'pg_proc') as comment
|
|
40
|
+
FROM pg_proc
|
|
41
|
+
JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid
|
|
42
|
+
WHERE pg_namespace.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
43
|
+
AND pg_proc.proname = '#{update_function_name}'
|
|
44
|
+
SQL
|
|
45
|
+
|
|
46
|
+
return unless row
|
|
47
|
+
|
|
48
|
+
metadata = JSON.parse(row["comment"])
|
|
49
|
+
|
|
50
|
+
VersioningHookDefinition.new(
|
|
51
|
+
metadata["source_table"],
|
|
52
|
+
metadata["history_table"],
|
|
53
|
+
columns: metadata["columns"],
|
|
54
|
+
primary_key: metadata["primary_key"]
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def change_versioning_hook(source_table, history_table, options)
|
|
59
|
+
add_columns = (options[:add_columns] || []).map(&:to_s)
|
|
60
|
+
remove_columns = (options[:remove_columns] || []).map(&:to_s)
|
|
61
|
+
|
|
62
|
+
ensure_table_exists!(source_table)
|
|
63
|
+
ensure_table_exists!(history_table)
|
|
64
|
+
ensure_columns_match!(source_table, history_table, add_columns)
|
|
65
|
+
|
|
66
|
+
hook_definition = versioning_hook(source_table)
|
|
67
|
+
|
|
68
|
+
ensure_hook_has_columns!(hook_definition, remove_columns)
|
|
69
|
+
|
|
70
|
+
drop_versioning_hook(source_table, history_table)
|
|
71
|
+
|
|
72
|
+
new_columns = hook_definition.columns + add_columns - remove_columns
|
|
73
|
+
|
|
74
|
+
create_versioning_hook(source_table, history_table, columns: new_columns)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def history_table(source_table)
|
|
78
|
+
hook_definition = versioning_hook(source_table)
|
|
79
|
+
|
|
80
|
+
hook_definition&.history_table
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def versioning_function_name(source_table, verb)
|
|
84
|
+
identifier = "#{source_table}_#{verb}"
|
|
85
|
+
hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
|
|
86
|
+
|
|
87
|
+
"sys_ver_func_#{hashed_identifier}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def ensure_table_exists!(table_name)
|
|
93
|
+
return if table_exists?(table_name)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "table '#{table_name}' does not exist"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ensure_columns_match!(source_table, history_table, column_names)
|
|
99
|
+
ensure_columns_exists!(source_table, column_names)
|
|
100
|
+
ensure_columns_exists!(history_table, column_names)
|
|
101
|
+
|
|
102
|
+
column_names.each do |column|
|
|
103
|
+
source_column = columns(source_table).find { _1.name == column }
|
|
104
|
+
history_column = columns(history_table).find { _1.name == column }
|
|
105
|
+
|
|
106
|
+
if source_column.type != history_column.type
|
|
107
|
+
raise ArgumentError, "table '#{history_table}' does not have column '#{column}' of type '#{source_column.type}'"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ensure_columns_exists!(table_name, column_names)
|
|
113
|
+
column_names.each do |column|
|
|
114
|
+
next if column_exists?(table_name, column)
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "table '#{table_name}' does not have column '#{column}'"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_hook_has_columns!(hook, column_names)
|
|
121
|
+
column_names.each do |column_name|
|
|
122
|
+
next if hook.columns.include?(column_name)
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, "versioning hook between '#{hook.source_table}' and '#{hook.history_table}' does not have column '#{column_name}'"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ActiveRecord::Temporal
|
|
2
|
+
module SystemVersioning
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
class_methods do
|
|
6
|
+
def history_table
|
|
7
|
+
connection.history_table(table_name)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def primary_key_from_db
|
|
11
|
+
Array(connection.primary_key(table_name)).map(&:to_sym)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def version_model
|
|
15
|
+
"Version::#{name}".constantize
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def system_versioning(namespace: "Version")
|
|
19
|
+
unless Object.const_defined?(namespace)
|
|
20
|
+
mod = Module.new
|
|
21
|
+
mod.include(Namespace)
|
|
22
|
+
Object.const_set(namespace, mod)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
|
|
3
|
+
require_relative "temporal/application_versioning"
|
|
4
|
+
require_relative "temporal/as_of_query"
|
|
5
|
+
require_relative "temporal/as_of_query/association_macros"
|
|
6
|
+
require_relative "temporal/as_of_query/association_scope"
|
|
7
|
+
require_relative "temporal/as_of_query/association_walker"
|
|
8
|
+
require_relative "temporal/as_of_query/query_methods"
|
|
9
|
+
require_relative "temporal/as_of_query/scope_registry"
|
|
10
|
+
require_relative "temporal/as_of_query/time_dimensions"
|
|
11
|
+
require_relative "temporal/patches/association_reflection"
|
|
12
|
+
require_relative "temporal/patches/join_dependency"
|
|
13
|
+
require_relative "temporal/patches/merger"
|
|
14
|
+
require_relative "temporal/patches/relation"
|
|
15
|
+
require_relative "temporal/patches/through_association"
|
|
16
|
+
require_relative "temporal/system_versioning"
|
|
17
|
+
require_relative "temporal/system_versioning/command_recorder"
|
|
18
|
+
require_relative "temporal/system_versioning/namespace"
|
|
19
|
+
require_relative "temporal/system_versioning/model"
|
|
20
|
+
require_relative "temporal/system_versioning/schema_creation"
|
|
21
|
+
require_relative "temporal/system_versioning/schema_definitions"
|
|
22
|
+
require_relative "temporal/system_versioning/schema_statements"
|
|
23
|
+
|
|
24
|
+
ActiveSupport.on_load(:active_record) do
|
|
25
|
+
require "active_record/connection_adapters/postgresql_adapter" # TODO: add test
|
|
26
|
+
|
|
27
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(ActiveRecord::Temporal::SystemVersioning::SchemaStatements)
|
|
28
|
+
ActiveRecord::Migration::CommandRecorder.include(ActiveRecord::Temporal::SystemVersioning::CommandRecorder)
|
|
29
|
+
ActiveRecord::Relation.include(ActiveRecord::Temporal::AsOfQuery::QueryMethods)
|
|
30
|
+
|
|
31
|
+
# Patches
|
|
32
|
+
|
|
33
|
+
# Patches the `build_arel` method wrap itself in the as-of query scope registry.
|
|
34
|
+
# This is what allows temporal association scopes to be aware of the time-scope
|
|
35
|
+
# value of the relation that included them.
|
|
36
|
+
#
|
|
37
|
+
# Patches the `instantiate_records` method to call `initialize_time_scope_from_relation`
|
|
38
|
+
# on each loaded record.
|
|
39
|
+
ActiveRecord::Relation.prepend(ActiveRecord::Temporal::Patches::Relation)
|
|
40
|
+
|
|
41
|
+
# Patches the `merge` method (called by `Relation#merge`) to handle the new
|
|
42
|
+
# query method `time_scope` that this gem adds.
|
|
43
|
+
ActiveRecord::Relation::Merger.prepend(ActiveRecord::Temporal::Patches::Merger)
|
|
44
|
+
|
|
45
|
+
# Patches the preloader's `through_scope` method to pass along the relation's
|
|
46
|
+
# time-scope values when it handles has-many-through associations. The handler
|
|
47
|
+
# for has-many assoication uses `Relation#merge`, but this one doesn't.
|
|
48
|
+
ActiveRecord::Associations::Preloader::ThroughAssociation.prepend(ActiveRecord::Temporal::Patches::ThroughAssociation)
|
|
49
|
+
|
|
50
|
+
# This permits association scopes generated by this gem to be eager-load if they
|
|
51
|
+
# are "optionally instance-dependent." That is to say, they accept arguments,
|
|
52
|
+
# but don't require any arguments.
|
|
53
|
+
#
|
|
54
|
+
# I think permitting eager-loading optionally instance-dependent association
|
|
55
|
+
# scopes would make sense as a general feature. See this PR for my justification:
|
|
56
|
+
# https://github.com/rails/rails/pull/56004
|
|
57
|
+
ActiveRecord::Reflection::AssociationReflection.prepend(ActiveRecord::Temporal::Patches::AssociationReflection)
|
|
58
|
+
|
|
59
|
+
# This is a copy of a fix from https://github.com/rails/rails/pull/56088 that
|
|
60
|
+
# impacts this gem. I has been backported to supported stable versions of
|
|
61
|
+
# Active Record, but until those patches are released it's included here.
|
|
62
|
+
ActiveRecord::Associations::JoinDependency.prepend(ActiveRecord::Temporal::Patches::JoinDependency)
|
|
63
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-temporal
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Martin-Alexander
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activesupport
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.2'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.2'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: pg
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '1.0'
|
|
59
|
+
type: :runtime
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '1.0'
|
|
66
|
+
description: An unobtrusive and modular plugin for Active Record that adds support
|
|
67
|
+
for time-travel querying and data versioning at the application level, at the system
|
|
68
|
+
level via PostgreSQL triggers, or both.
|
|
69
|
+
email:
|
|
70
|
+
- martingianna@gmail.com
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- CHANGELOG.md
|
|
76
|
+
- LICENSE.txt
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/activerecord/temporal.rb
|
|
79
|
+
- lib/activerecord/temporal/application_versioning.rb
|
|
80
|
+
- lib/activerecord/temporal/as_of_query.rb
|
|
81
|
+
- lib/activerecord/temporal/as_of_query/association_macros.rb
|
|
82
|
+
- lib/activerecord/temporal/as_of_query/association_scope.rb
|
|
83
|
+
- lib/activerecord/temporal/as_of_query/association_walker.rb
|
|
84
|
+
- lib/activerecord/temporal/as_of_query/query_methods.rb
|
|
85
|
+
- lib/activerecord/temporal/as_of_query/scope_registry.rb
|
|
86
|
+
- lib/activerecord/temporal/as_of_query/time_dimensions.rb
|
|
87
|
+
- lib/activerecord/temporal/patches/association_reflection.rb
|
|
88
|
+
- lib/activerecord/temporal/patches/join_dependency.rb
|
|
89
|
+
- lib/activerecord/temporal/patches/merger.rb
|
|
90
|
+
- lib/activerecord/temporal/patches/relation.rb
|
|
91
|
+
- lib/activerecord/temporal/patches/through_association.rb
|
|
92
|
+
- lib/activerecord/temporal/system_versioning.rb
|
|
93
|
+
- lib/activerecord/temporal/system_versioning/command_recorder.rb
|
|
94
|
+
- lib/activerecord/temporal/system_versioning/model.rb
|
|
95
|
+
- lib/activerecord/temporal/system_versioning/namespace.rb
|
|
96
|
+
- lib/activerecord/temporal/system_versioning/schema_creation.rb
|
|
97
|
+
- lib/activerecord/temporal/system_versioning/schema_definitions.rb
|
|
98
|
+
- lib/activerecord/temporal/system_versioning/schema_statements.rb
|
|
99
|
+
- lib/activerecord/temporal/version.rb
|
|
100
|
+
homepage: https://github.com/Martin-Alexander/activerecord-temporal
|
|
101
|
+
licenses:
|
|
102
|
+
- MIT
|
|
103
|
+
metadata:
|
|
104
|
+
bug_tracker_uri: https://github.com/Martin-Alexander/activerecord-temporal/issues
|
|
105
|
+
changelog_uri: https://github.com/Martin-Alexander/activerecord-temporal/CHANGELOG.md
|
|
106
|
+
homepage_uri: https://github.com/Martin-Alexander/activerecord-temporal
|
|
107
|
+
source_code_uri: https://github.com/Martin-Alexander/activerecord-temporal
|
|
108
|
+
rdoc_options: []
|
|
109
|
+
require_paths:
|
|
110
|
+
- lib
|
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: 3.2.0
|
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '0'
|
|
121
|
+
requirements: []
|
|
122
|
+
rubygems_version: 3.7.2
|
|
123
|
+
specification_version: 4
|
|
124
|
+
summary: Time-travel querying, application/system versioning, and bitemporal support
|
|
125
|
+
for Active Record
|
|
126
|
+
test_files: []
|