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.
Files changed (27) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +12 -0
  5. data/lib/activerecord/temporal/application_versioning.rb +77 -0
  6. data/lib/activerecord/temporal/as_of_query/association_macros.rb +41 -0
  7. data/lib/activerecord/temporal/as_of_query/association_scope.rb +54 -0
  8. data/lib/activerecord/temporal/as_of_query/association_walker.rb +40 -0
  9. data/lib/activerecord/temporal/as_of_query/query_methods.rb +24 -0
  10. data/lib/activerecord/temporal/as_of_query/scope_registry.rb +38 -0
  11. data/lib/activerecord/temporal/as_of_query/time_dimensions.rb +69 -0
  12. data/lib/activerecord/temporal/as_of_query.rb +109 -0
  13. data/lib/activerecord/temporal/patches/association_reflection.rb +19 -0
  14. data/lib/activerecord/temporal/patches/join_dependency.rb +53 -0
  15. data/lib/activerecord/temporal/patches/merger.rb +11 -0
  16. data/lib/activerecord/temporal/patches/relation.rb +29 -0
  17. data/lib/activerecord/temporal/patches/through_association.rb +11 -0
  18. data/lib/activerecord/temporal/system_versioning/command_recorder.rb +47 -0
  19. data/lib/activerecord/temporal/system_versioning/model.rb +37 -0
  20. data/lib/activerecord/temporal/system_versioning/namespace.rb +34 -0
  21. data/lib/activerecord/temporal/system_versioning/schema_creation.rb +147 -0
  22. data/lib/activerecord/temporal/system_versioning/schema_definitions.rb +37 -0
  23. data/lib/activerecord/temporal/system_versioning/schema_statements.rb +129 -0
  24. data/lib/activerecord/temporal/system_versioning.rb +27 -0
  25. data/lib/activerecord/temporal/version.rb +3 -0
  26. data/lib/activerecord/temporal.rb +63 -0
  27. 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-11-11
4
+
5
+ - Initial release
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,12 @@
1
+ # Active Record Temporal
2
+
3
+ ## Features
4
+
5
+ - Flashback (or as-of) queries
6
+ - Application versioning
7
+ - System versioning
8
+
9
+ ## Contributing
10
+
11
+ - Run `rake db:create` to create the PostgreSQL test database
12
+ - Run `rake` to run tests and linter
@@ -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,11 @@
1
+ module ActiveRecord::Temporal
2
+ module Patches
3
+ module Merger
4
+ def merge
5
+ super.tap do |relation|
6
+ relation.time_scope!(values[:time_scope] || {})
7
+ end
8
+ end
9
+ end
10
+ end
11
+ 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,11 @@
1
+ module ActiveRecord::Temporal
2
+ module Patches
3
+ module ThroughAssociation
4
+ def through_scope
5
+ super.tap do |scope|
6
+ scope.time_scope_values = reflection_scope.time_scope_values
7
+ end
8
+ end
9
+ end
10
+ end
11
+ 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,3 @@
1
+ module ActiveRecord::Temporal
2
+ VERSION = "0.1.0"
3
+ 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: []