activerecord-temporal 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +775 -7
  4. data/lib/activerecord/temporal/application_versioning/application_versioned.rb +122 -0
  5. data/lib/activerecord/temporal/application_versioning/command_recorder.rb +14 -0
  6. data/lib/activerecord/temporal/application_versioning/migration.rb +25 -0
  7. data/lib/activerecord/temporal/application_versioning/schema_statements.rb +33 -0
  8. data/lib/activerecord/temporal/application_versioning.rb +3 -69
  9. data/lib/activerecord/temporal/patches/association_reflection.rb +10 -3
  10. data/lib/activerecord/temporal/patches/command_recorder.rb +23 -0
  11. data/lib/activerecord/temporal/patches/join_dependency.rb +3 -0
  12. data/lib/activerecord/temporal/patches/merger.rb +1 -1
  13. data/lib/activerecord/temporal/patches/relation.rb +10 -6
  14. data/lib/activerecord/temporal/patches/through_association.rb +4 -1
  15. data/lib/activerecord/temporal/{as_of_query → querying}/association_macros.rb +1 -1
  16. data/lib/activerecord/temporal/querying/association_scope.rb +55 -0
  17. data/lib/activerecord/temporal/{as_of_query → querying}/association_walker.rb +1 -1
  18. data/lib/activerecord/temporal/querying/predicate_builder/contains_handler.rb +24 -0
  19. data/lib/activerecord/temporal/querying/predicate_builder/handlers.rb +31 -0
  20. data/lib/activerecord/temporal/querying/query_methods.rb +37 -0
  21. data/lib/activerecord/temporal/querying/scope_registry.rb +95 -0
  22. data/lib/activerecord/temporal/querying/scoping.rb +70 -0
  23. data/lib/activerecord/temporal/{as_of_query → querying}/time_dimensions.rb +13 -3
  24. data/lib/activerecord/temporal/querying/where_clause_refinement.rb +17 -0
  25. data/lib/activerecord/temporal/querying.rb +95 -0
  26. data/lib/activerecord/temporal/scoping.rb +7 -0
  27. data/lib/activerecord/temporal/system_versioning/command_recorder.rb +27 -1
  28. data/lib/activerecord/temporal/system_versioning/history_model.rb +47 -0
  29. data/lib/activerecord/temporal/system_versioning/history_model_namespace.rb +45 -0
  30. data/lib/activerecord/temporal/system_versioning/history_models.rb +29 -0
  31. data/lib/activerecord/temporal/system_versioning/migration.rb +35 -0
  32. data/lib/activerecord/temporal/system_versioning/schema_creation.rb +2 -2
  33. data/lib/activerecord/temporal/system_versioning/schema_statements.rb +80 -8
  34. data/lib/activerecord/temporal/system_versioning/system_versioned.rb +13 -0
  35. data/lib/activerecord/temporal/system_versioning.rb +6 -18
  36. data/lib/activerecord/temporal/version.rb +1 -1
  37. data/lib/activerecord/temporal.rb +75 -30
  38. metadata +27 -14
  39. data/lib/activerecord/temporal/as_of_query/association_scope.rb +0 -54
  40. data/lib/activerecord/temporal/as_of_query/query_methods.rb +0 -24
  41. data/lib/activerecord/temporal/as_of_query/scope_registry.rb +0 -38
  42. data/lib/activerecord/temporal/as_of_query.rb +0 -109
  43. data/lib/activerecord/temporal/system_versioning/model.rb +0 -37
  44. 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 "not head revision" 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 "not head revision" 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 "not head revision" unless head_revision?
116
+
117
+ set_time_dimension_end(time)
118
+ save
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,14 @@
1
+ module ActiveRecord::Temporal
2
+ module ApplicationVersioning
3
+ module CommandRecorder
4
+ def create_application_versioned_table(*args)
5
+ record(:create_application_versioned_table, args)
6
+ end
7
+ ruby2_keywords(:create_application_versioned_table)
8
+
9
+ def invert_create_application_versioned_table(args)
10
+ [:drop_table, args]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ module ActiveRecord::Temporal
2
+ module ApplicationVersioning
3
+ module Migration
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ prepend Patches
8
+ end
9
+
10
+ module Patches
11
+ def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options, &block)
12
+ application_versioning = options.delete(:application_versioning)
13
+
14
+ if application_versioning
15
+ create_application_versioned_table(
16
+ table_name, id:, primary_key:, force:, **options, &block
17
+ )
18
+ else
19
+ super
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ module ActiveRecord::Temporal
2
+ module ApplicationVersioning
3
+ module SchemaStatements
4
+ def create_application_versioned_table(table_name, **options, &block)
5
+ pk_option = options[:primary_key]
6
+
7
+ primary_key = if options[:primary_key]
8
+ Array(options[:primary_key]) | [:version]
9
+ else
10
+ [:id, :version]
11
+ end
12
+
13
+ exclusion_constraint_expression = (primary_key - [:version]).map do |col|
14
+ "#{col} WITH ="
15
+ end.join(", ") + ", validity WITH &&"
16
+
17
+ options = options.merge(primary_key: primary_key)
18
+
19
+ create_table(table_name, **options) do |t|
20
+ unless pk_option.is_a?(Array)
21
+ t.bigserial pk_option || :id, null: false
22
+ end
23
+
24
+ t.bigint :version, null: false, default: 1
25
+ t.tstzrange :validity, null: false
26
+ t.exclusion_constraint exclusion_constraint_expression, using: :gist
27
+
28
+ instance_exec(t, &block)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2,76 +2,10 @@ 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
32
-
33
- [new_revision, record]
5
+ class_methods do
6
+ def application_versioned
7
+ include ApplicationVersioned
34
8
  end
35
9
  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
10
  end
77
11
  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?
@@ -0,0 +1,23 @@
1
+ module ActiveRecord::Temporal
2
+ module Patches
3
+ module CommandRecorder
4
+ def invert_drop_table(args, &block)
5
+ if extract_options(args).delete(:system_versioning)
6
+ raise ActiveRecord::IrreversibleMigration, "drop_table with system versioning is not supported"
7
+ end
8
+
9
+ super
10
+ end
11
+
12
+ private
13
+
14
+ def extract_options(array)
15
+ if array.last.is_a?(Hash) && array.last.extractable_options?
16
+ array.last
17
+ else
18
+ {}
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,8 @@
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 backported to supported stable versions of
5
+ # Active Record, but until those patches are released it's included here.
3
6
  module JoinDependency
4
7
  def instantiate(result_set, strict_loading_value, &block)
5
8
  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