activerecord-temporal 0.1.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +778 -7
  4. data/lib/activerecord/temporal/application_versioning/application_versioned.rb +122 -0
  5. data/lib/activerecord/temporal/application_versioning.rb +4 -68
  6. data/lib/activerecord/temporal/patches/association_reflection.rb +10 -3
  7. data/lib/activerecord/temporal/patches/join_dependency.rb +4 -0
  8. data/lib/activerecord/temporal/patches/merger.rb +1 -1
  9. data/lib/activerecord/temporal/patches/relation.rb +10 -6
  10. data/lib/activerecord/temporal/patches/through_association.rb +4 -1
  11. data/lib/activerecord/temporal/{as_of_query → querying}/association_macros.rb +1 -1
  12. data/lib/activerecord/temporal/querying/association_scope.rb +55 -0
  13. data/lib/activerecord/temporal/{as_of_query → querying}/association_walker.rb +1 -1
  14. data/lib/activerecord/temporal/querying/predicate_builder/contains_handler.rb +24 -0
  15. data/lib/activerecord/temporal/querying/predicate_builder/handlers.rb +31 -0
  16. data/lib/activerecord/temporal/querying/query_methods.rb +37 -0
  17. data/lib/activerecord/temporal/querying/scope_registry.rb +95 -0
  18. data/lib/activerecord/temporal/querying/scoping.rb +70 -0
  19. data/lib/activerecord/temporal/{as_of_query → querying}/time_dimensions.rb +13 -3
  20. data/lib/activerecord/temporal/querying/where_clause_refinement.rb +17 -0
  21. data/lib/activerecord/temporal/querying.rb +95 -0
  22. data/lib/activerecord/temporal/scoping.rb +7 -0
  23. data/lib/activerecord/temporal/system_versioning/history_model.rb +47 -0
  24. data/lib/activerecord/temporal/system_versioning/history_model_namespace.rb +45 -0
  25. data/lib/activerecord/temporal/system_versioning/history_models.rb +29 -0
  26. data/lib/activerecord/temporal/system_versioning/schema_creation.rb +8 -5
  27. data/lib/activerecord/temporal/system_versioning/schema_definitions.rb +10 -8
  28. data/lib/activerecord/temporal/system_versioning/schema_statements.rb +62 -24
  29. data/lib/activerecord/temporal/system_versioning/system_versioned.rb +13 -0
  30. data/lib/activerecord/temporal/system_versioning.rb +6 -18
  31. data/lib/activerecord/temporal/version.rb +1 -1
  32. data/lib/activerecord/temporal.rb +64 -33
  33. metadata +23 -15
  34. data/lib/activerecord/temporal/as_of_query/association_scope.rb +0 -54
  35. data/lib/activerecord/temporal/as_of_query/query_methods.rb +0 -24
  36. data/lib/activerecord/temporal/as_of_query/scope_registry.rb +0 -38
  37. data/lib/activerecord/temporal/as_of_query.rb +0 -109
  38. data/lib/activerecord/temporal/system_versioning/model.rb +0 -37
  39. data/lib/activerecord/temporal/system_versioning/namespace.rb +0 -34
@@ -0,0 +1,122 @@
1
+ module ActiveRecord::Temporal
2
+ module ApplicationVersioning
3
+ module ApplicationVersioned
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ delegate :scope_time, to: :class
8
+
9
+ include Querying
10
+ end
11
+
12
+ class_methods do
13
+ def originate
14
+ originate_at(scope_time || Time.current)
15
+ end
16
+
17
+ def originate_at(time)
18
+ Original.new(self, time, save: true)
19
+ end
20
+
21
+ def original
22
+ original_at(scope_time || Time.current)
23
+ end
24
+
25
+ def original_at(time)
26
+ Original.new(self, time)
27
+ end
28
+
29
+ def scope_time
30
+ Querying::ScopeRegistry.global_constraint_for(time_dimensions.first)
31
+ end
32
+ end
33
+
34
+ class Revision
35
+ attr_reader :record, :time, :options
36
+
37
+ def initialize(record, time, **options)
38
+ @record = record
39
+ @time = time
40
+ @options = options
41
+ end
42
+
43
+ def with(attributes)
44
+ new_revision = record.dup
45
+ new_revision.assign_attributes(attributes)
46
+ new_revision.id = record.id
47
+ new_revision.set_time_dimension_start(time)
48
+ new_revision.time_tags = record.time_tags
49
+ record.set_time_dimension_end(time)
50
+
51
+ new_revision.after_initialize_revision(record)
52
+
53
+ if options[:save]
54
+ record.class.transaction do
55
+ new_revision.save if record.save
56
+ end
57
+ end
58
+
59
+ new_revision
60
+ end
61
+ end
62
+
63
+ class Original
64
+ attr_reader :klass, :time, :options
65
+
66
+ def initialize(klass, time, **options)
67
+ @klass = klass
68
+ @time = time
69
+ @options = options
70
+ end
71
+
72
+ def with(attributes)
73
+ new_record = klass.new(attributes)
74
+ new_record.set_time_dimension_start(time)
75
+
76
+ new_record.save if options[:save]
77
+
78
+ new_record
79
+ end
80
+ end
81
+
82
+ def after_initialize_revision(old_revision)
83
+ self.version = old_revision.version + 1
84
+ end
85
+
86
+ def head_revision?
87
+ time_dimension && !time_dimension_end
88
+ end
89
+
90
+ def revise
91
+ revise_at(scope_time || Time.current)
92
+ end
93
+
94
+ def revise_at(time)
95
+ raise ClosedRevisionError, "Cannot revise closed version" unless head_revision?
96
+
97
+ Revision.new(self, time, save: true)
98
+ end
99
+
100
+ def revision
101
+ revision_at(scope_time || Time.current)
102
+ end
103
+
104
+ def revision_at(time)
105
+ raise ClosedRevisionError, "Cannot revise closed version" unless head_revision?
106
+
107
+ Revision.new(self, time, save: false)
108
+ end
109
+
110
+ def inactivate
111
+ inactivate_at(scope_time || Time.current)
112
+ end
113
+
114
+ def inactivate_at(time)
115
+ raise ClosedRevisionError, "Cannot inactivate closed version" unless head_revision?
116
+
117
+ set_time_dimension_end(time)
118
+ save
119
+ end
120
+ end
121
+ end
122
+ end
@@ -2,76 +2,12 @@ module ActiveRecord::Temporal
2
2
  module ApplicationVersioning
3
3
  extend ActiveSupport::Concern
4
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
5
+ class ClosedRevisionError < StandardError; end
32
6
 
33
- [new_revision, record]
7
+ class_methods do
8
+ def application_versioned
9
+ include ApplicationVersioned
34
10
  end
35
11
  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
12
  end
77
13
  end
@@ -1,14 +1,21 @@
1
1
  module ActiveRecord::Temporal
2
2
  module Patches
3
+ # This permits association scopes generated by this gem to be eager-load if
4
+ # they are "optionally instance-dependent." That is to say, they accept but
5
+ # don't require arguments.
6
+ #
7
+ # I think permitting eager-loading of such scopes would make sense as a
8
+ # standalone feature for Active Record. See this PR for my justification:
9
+ # https://github.com/rails/rails/pull/56004
3
10
  module AssociationReflection
4
11
  def check_eager_loadable!
5
- super unless as_of_scope? && scope_requires_no_params?
12
+ super unless temporal_scope? && scope_requires_no_params?
6
13
  end
7
14
 
8
15
  private
9
16
 
10
- def as_of_scope?
11
- scope.respond_to?(:as_of_scope?) && scope.as_of_scope?
17
+ def temporal_scope?
18
+ scope.respond_to?(:temporal_scope?) && scope.temporal_scope?
12
19
  end
13
20
 
14
21
  def scope_requires_no_params?
@@ -1,5 +1,9 @@
1
1
  module ActiveRecord::Temporal
2
2
  module Patches
3
+ # This is a copy of a fix from https://github.com/rails/rails/pull/56088 that
4
+ # impacts this gem. I has been merged and backported to supported stable
5
+ # versions of Active Record, but until those patches are released it's included
6
+ # here.
3
7
  module JoinDependency
4
8
  def instantiate(result_set, strict_loading_value, &block)
5
9
  primary_key = Array(join_root.primary_key).map { |column| aliases.column_alias(join_root, column) }
@@ -3,7 +3,7 @@ module ActiveRecord::Temporal
3
3
  module Merger
4
4
  def merge
5
5
  super.tap do |relation|
6
- relation.time_scope!(values[:time_scope] || {})
6
+ relation.time_tags!(values[:time_tags] || {})
7
7
  end
8
8
  end
9
9
  end
@@ -4,22 +4,26 @@ module ActiveRecord::Temporal
4
4
  private
5
5
 
6
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 }
7
+ def build_arel(aliases)
8
+ Querying::Scoping.as_of(time_tag_values) do
9
+ super
10
+ end
9
11
  end
10
12
  else
11
- def build_arel(connection, aliases = nil)
12
- AsOfQuery::ScopeRegistry.with_query_scope(time_scope_values) { super }
13
+ def build_arel(aliases, connection = nil)
14
+ Querying::Scoping.as_of(time_tag_values) do
15
+ super
16
+ end
13
17
  end
14
18
  end
15
19
 
16
20
  def instantiate_records(rows, &block)
17
- return super if time_scope_values.empty?
21
+ return super if time_tag_values.empty?
18
22
 
19
23
  records = super
20
24
 
21
25
  records.each do |record|
22
- record.initialize_time_scope_from_relation(self) if record.is_a?(AsOfQuery)
26
+ record.initialize_time_tags_from_relation(self) if record.is_a?(Querying)
23
27
  end
24
28
 
25
29
  records
@@ -1,9 +1,12 @@
1
1
  module ActiveRecord::Temporal
2
2
  module Patches
3
+ # Patches the preloader's `through_scope` method to pass along the relation's
4
+ # time tag values when it handles has-many-through associations. The handler
5
+ # for has-many associations uses `Relation#merge`, but this one doesn't.
3
6
  module ThroughAssociation
4
7
  def through_scope
5
8
  super.tap do |scope|
6
- scope.time_scope_values = reflection_scope.time_scope_values
9
+ scope.time_tag_values = reflection_scope.time_tag_values
7
10
  end
8
11
  end
9
12
  end
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord::Temporal
2
- module AsOfQuery
2
+ module Querying
3
3
  module AssociationMacros
4
4
  extend ActiveSupport::Concern
5
5
 
@@ -0,0 +1,55 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ class AssociationScope
4
+ class << self
5
+ def build(block)
6
+ scope = merge_scopes(block)
7
+
8
+ def scope.temporal_scope? = true
9
+
10
+ scope
11
+ end
12
+
13
+ private
14
+
15
+ def merge_scopes(block)
16
+ temporal_scope = build_temporal_scope
17
+
18
+ if !block
19
+ return ->(owner = nil) do
20
+ instance_exec(owner, all, &temporal_scope)
21
+ end
22
+ end
23
+
24
+ if block.arity != 0
25
+ return ->(owner) do
26
+ base = instance_exec(owner, &block)
27
+ instance_exec(owner, base, &temporal_scope)
28
+ end
29
+ end
30
+
31
+ ->(owner = nil) do
32
+ base = instance_exec(&block)
33
+ instance_exec(owner, base, &temporal_scope)
34
+ end
35
+ end
36
+
37
+ def build_temporal_scope
38
+ ->(owner, base) do
39
+ registry_constraints = ScopeRegistry
40
+ .association_constraints_for(time_dimensions)
41
+
42
+ registry_time_tags = ScopeRegistry
43
+ .association_tags_for(time_dimensions)
44
+
45
+ owner_time_tags = owner&.time_tags_for(time_dimensions) || {}
46
+
47
+ base
48
+ .at_time(registry_constraints.merge(owner_time_tags))
49
+ .time_tags(owner_time_tags.merge(registry_time_tags))
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord::Temporal
2
- module AsOfQuery
2
+ module Querying
3
3
  class AssociationWalker
4
4
  class << self
5
5
  def each_target(parent_record, associations, &block)
@@ -0,0 +1,24 @@
1
+ module ActiveRecord::Temporal::Querying
2
+ class PredicateBuilder
3
+ class ContainsHandler
4
+ def initialize(predicate_builder)
5
+ @predicate_builder = predicate_builder
6
+ end
7
+
8
+ def call(attribute, value)
9
+ time_as_tstz = Arel::Nodes::As.new(
10
+ Arel::Nodes::Quoted.new(value.time),
11
+ Arel::Nodes::SqlLiteral.new("timestamptz")
12
+ )
13
+
14
+ cast_value = Arel::Nodes::NamedFunction.new("CAST", [time_as_tstz])
15
+
16
+ attribute.contains(cast_value)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :predicate_builder
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ module ActiveRecord::Temporal::Querying
2
+ class PredicateBuilder
3
+ require_relative "contains_handler"
4
+
5
+ module Handlers
6
+ Contains = Struct.new(:time)
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ def contains(time)
12
+ Contains.new(time)
13
+ end
14
+
15
+ def predicate_builder
16
+ super.tap do |base_predicate_builder|
17
+ register_predicate_builder_handlers(base_predicate_builder)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def register_predicate_builder_handlers(base_predicate_builder)
24
+ @register_predicate_builder_handlers ||= base_predicate_builder
25
+ .register_handler Contains,
26
+ PredicateBuilder::ContainsHandler.new(base_predicate_builder)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ module QueryMethods
4
+ require "activerecord/temporal/querying/where_clause_refinement"
5
+
6
+ using Querying::WhereClauseRefinement
7
+
8
+ def time_tags(scope)
9
+ spawn.time_tags!(scope)
10
+ end
11
+
12
+ def time_tags!(scope)
13
+ self.time_tag_values = time_tag_values.merge(scope)
14
+ self
15
+ end
16
+
17
+ def time_tag_values
18
+ @values.fetch(:time_tags, ActiveRecord::QueryMethods::FROZEN_EMPTY_HASH)
19
+ end
20
+
21
+ def time_tag_values=(scope)
22
+ assert_modifiable! # TODO: write test
23
+
24
+ @values[:time_tags] = scope
25
+ end
26
+
27
+ def rewhere_contains(conditions)
28
+ scope = spawn
29
+
30
+ scope.where_clause = where_clause.except_contains(conditions.keys)
31
+ scope.where_clause += build_where_clause(conditions)
32
+
33
+ scope
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,95 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ class ScopeRegistry
4
+ class << self
5
+ delegate :global_constraints,
6
+ :association_constraints,
7
+ :association_tags,
8
+ :universal_global_constraint_time,
9
+ :global_constraints=,
10
+ :association_constraints=,
11
+ :association_tags=,
12
+ :universal_global_constraint_time=,
13
+ :global_constraint_for,
14
+ :association_constraint_for,
15
+ :association_tag_for,
16
+ :global_constraints_for,
17
+ :association_constraints_for,
18
+ :association_tags_for,
19
+ :set_global_constraints,
20
+ :set_association_constraints,
21
+ :set_association_tags,
22
+ to: :instance
23
+
24
+ def instance
25
+ ActiveSupport::IsolatedExecutionState[:temporal_querying_registry] ||= new
26
+ end
27
+ end
28
+
29
+ attr_accessor :global_constraints,
30
+ :association_constraints,
31
+ :association_tags,
32
+ :universal_global_constraint_time
33
+
34
+ def initialize(
35
+ global_constraints: nil,
36
+ association_constraints: nil,
37
+ association_tags: nil,
38
+ default_association_constraint_time: nil,
39
+ universal_global_constraint_time: nil
40
+ )
41
+ @global_constraints = global_constraints || {}
42
+ @association_constraints = association_constraints || {}
43
+ @association_tags = association_tags || {}
44
+ @default_association_constraint_time = default_association_constraint_time ||
45
+ -> { Time.current }
46
+ @universal_global_constraint_time = universal_global_constraint_time
47
+ end
48
+
49
+ def global_constraint_for(dimension)
50
+ global_constraints[dimension] || universal_global_constraint_time
51
+ end
52
+
53
+ def association_constraint_for(dimension)
54
+ association_constraints[dimension] ||
55
+ global_constraint_for(dimension) ||
56
+ @default_association_constraint_time.call
57
+ end
58
+
59
+ def association_tag_for(dimension)
60
+ association_tags[dimension]
61
+ end
62
+
63
+ def global_constraints_for(*dimensions)
64
+ dimensions.flatten.index_with do |dimension|
65
+ global_constraint_for(dimension)
66
+ end.compact
67
+ end
68
+
69
+ def association_constraints_for(*dimensions)
70
+ dimensions.flatten.index_with do |dimension|
71
+ association_constraint_for(dimension)
72
+ end
73
+ end
74
+
75
+ def association_tags_for(*dimensions)
76
+ association_tags.slice(*dimensions.flatten)
77
+ end
78
+
79
+ def set_global_constraints(time_coords)
80
+ self.global_constraints = global_constraints
81
+ .merge(time_coords)
82
+ end
83
+
84
+ def set_association_constraints(time_coords)
85
+ self.association_constraints = association_constraints
86
+ .merge(time_coords)
87
+ end
88
+
89
+ def set_association_tags(time_coords)
90
+ self.association_tags = association_tags
91
+ .merge(time_coords)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,70 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ class Scoping
4
+ class << self
5
+ def at(time_or_time_coords, &block)
6
+ if time_or_time_coords.is_a?(Hash)
7
+ with_global_constraint(time_or_time_coords, &block)
8
+ else
9
+ without_global_constraints do
10
+ with_universal_global_constraint_time(time_or_time_coords, &block)
11
+ end
12
+ end
13
+ end
14
+
15
+ def as_of(time_coords, &block)
16
+ with_association_constraints(time_coords) do
17
+ with_association_tags(time_coords, &block)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def with_association_constraints(time_coords, &block)
24
+ original = ScopeRegistry.association_constraints
25
+ ScopeRegistry.set_association_constraints(time_coords)
26
+
27
+ block.call
28
+ ensure
29
+ ScopeRegistry.association_constraints = original
30
+ end
31
+
32
+ def with_association_tags(time_coords, &block)
33
+ original = ScopeRegistry.association_tags
34
+ ScopeRegistry.set_association_tags(time_coords)
35
+
36
+ block.call
37
+ ensure
38
+ ScopeRegistry.association_tags = original
39
+ end
40
+
41
+ def with_global_constraint(value, &block)
42
+ original = ScopeRegistry.global_constraints
43
+ ScopeRegistry.set_global_constraints(value)
44
+
45
+ block.call
46
+ ensure
47
+ ScopeRegistry.global_constraints = original
48
+ end
49
+
50
+ def without_global_constraints(&block)
51
+ original = ScopeRegistry.global_constraints
52
+ ScopeRegistry.global_constraints = {}
53
+
54
+ block.call
55
+ ensure
56
+ ScopeRegistry.global_constraints = original
57
+ end
58
+
59
+ def with_universal_global_constraint_time(time, &block)
60
+ original = ScopeRegistry.universal_global_constraint_time
61
+ ScopeRegistry.universal_global_constraint_time = time
62
+
63
+ block.call
64
+ ensure
65
+ ScopeRegistry.universal_global_constraint_time = original
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord::Temporal
2
- module AsOfQuery
2
+ module Querying
3
3
  module TimeDimensions
4
4
  extend ActiveSupport::Concern
5
5
 
@@ -8,7 +8,9 @@ module ActiveRecord::Temporal
8
8
  end
9
9
 
10
10
  class_methods do
11
- def set_time_dimensions(*dimensions)
11
+ def time_dimensions=(*dimensions)
12
+ dimensions = dimensions.flatten
13
+
12
14
  define_singleton_method(:time_dimensions) { dimensions }
13
15
  define_singleton_method(:default_time_dimension) { dimensions.first }
14
16
  end
@@ -17,7 +19,15 @@ module ActiveRecord::Temporal
17
19
  def default_time_dimension = nil
18
20
 
19
21
  def time_dimension_column?(time_dimension)
20
- connection.column_exists?(table_name, time_dimension)
22
+ @time_dimension_column_cache ||= {}
23
+
24
+ @time_dimension_column_cache[time_dimension] ||= connection.column_exists?(table_name, time_dimension)
25
+ end
26
+
27
+ def time_dimension_columns
28
+ time_dimensions.select do |dimension|
29
+ time_dimension_column?(dimension)
30
+ end
21
31
  end
22
32
  end
23
33
 
@@ -0,0 +1,17 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ module WhereClauseRefinement
4
+ refine ActiveRecord::Relation::WhereClause do
5
+ def except_contains(columns)
6
+ columns = columns.map(&:to_s)
7
+
8
+ remaining_predications = predicates.reject do |node|
9
+ node.is_a?(Arel::Nodes::Contains) && columns.include?(node.left.name)
10
+ end
11
+
12
+ self.class.new(remaining_predications)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end