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,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
@@ -0,0 +1,95 @@
1
+ module ActiveRecord::Temporal
2
+ module Querying
3
+ class RangeError < StandardError; end
4
+
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def resolve_time_coords(time_or_time_coords)
9
+ return time_or_time_coords if time_or_time_coords.is_a?(Hash)
10
+
11
+ {default_time_dimension.to_sym => time_or_time_coords}
12
+ end
13
+ end
14
+
15
+ included do
16
+ include AssociationMacros
17
+ include TimeDimensions
18
+ include PredicateBuilder::Handlers
19
+
20
+ delegate :resolve_time_coords, to: :class
21
+
22
+ default_scope do
23
+ at_time(Querying::ScopeRegistry.global_constraints_for(time_dimensions))
24
+ end
25
+
26
+ scope :as_of, ->(time) do
27
+ time_coords = resolve_time_coords(time)
28
+
29
+ at_time(time_coords).time_tags(time_coords)
30
+ end
31
+
32
+ scope :at_time, ->(time) do
33
+ time_coords = resolve_time_coords(time)
34
+
35
+ constraints = time_coords.slice(*time_dimension_columns)
36
+
37
+ return if constraints.empty?
38
+
39
+ rewhere_contains(constraints.transform_values { |v| contains(v) })
40
+ end
41
+ end
42
+
43
+ def time_tags
44
+ @time_tags || {}
45
+ end
46
+
47
+ def time_tags=(value)
48
+ @time_tags = value&.slice(*time_dimensions)
49
+ end
50
+
51
+ def time_tag
52
+ time_tags[default_time_dimension]
53
+ end
54
+
55
+ def time_tags_for(time_dimensions)
56
+ time_tags.slice(*time_dimensions)
57
+ end
58
+
59
+ def as_of!(time)
60
+ time_coords = resolve_time_coords(time)
61
+
62
+ ensure_time_tags_in_bounds!(time_coords)
63
+
64
+ reload
65
+
66
+ self.time_tags = time_coords
67
+ end
68
+
69
+ def as_of(time)
70
+ time_coords = resolve_time_coords(time)
71
+
72
+ self.class.as_of(time_coords).find_by(self.class.primary_key => [id])
73
+ end
74
+
75
+ def initialize_time_tags_from_relation(relation)
76
+ associations = relation.includes_values | relation.eager_load_values
77
+
78
+ self.time_tags = relation.time_tag_values
79
+
80
+ AssociationWalker.each_target(self, associations) do |target|
81
+ target.time_tags = relation.time_tag_values
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def ensure_time_tags_in_bounds!(time_tags)
88
+ time_tags.each do |dimension, time|
89
+ if time_dimension_column?(dimension) && !time_dimension(dimension).cover?(time)
90
+ raise RangeError, "#{time} is outside of '#{dimension}' range"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveRecord::Temporal
2
+ module Scoping
3
+ def temporal_scoping
4
+ Querying::Scoping
5
+ end
6
+ end
7
+ end
@@ -1,10 +1,26 @@
1
1
  module ActiveRecord::Temporal
2
2
  module SystemVersioning
3
3
  module CommandRecorder
4
+ module ArrayExtractOptions
5
+ refine Array do
6
+ def extract_options
7
+ if last.is_a?(Hash) && last.extractable_options?
8
+ last
9
+ else
10
+ {}
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ using ArrayExtractOptions
17
+
4
18
  [
5
19
  :create_versioning_hook,
6
20
  :drop_versioning_hook,
7
- :change_versioning_hook
21
+ :change_versioning_hook,
22
+ :create_table_with_system_versioning,
23
+ :drop_table_with_system_versioning
8
24
  ].each do |method|
9
25
  class_eval <<-EOV, __FILE__, __LINE__ + 1
10
26
  def #{method}(*args)
@@ -42,6 +58,16 @@ module ActiveRecord::Temporal
42
58
  ]
43
59
  ]
44
60
  end
61
+
62
+ def invert_create_table_with_system_versioning(args)
63
+ [:drop_table_with_system_versioning, args]
64
+ end
65
+
66
+ def invert_drop_table_with_system_versioning(args)
67
+ # TODO make this reversible
68
+
69
+ raise ActiveRecord::IrreversibleMigration, "drop_table_with_system_versioning is not reversible"
70
+ end
45
71
  end
46
72
  end
47
73
  end
@@ -0,0 +1,47 @@
1
+ module ActiveRecord::Temporal
2
+ module SystemVersioning
3
+ module HistoryModel
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def polymorphic_class_for(name)
8
+ super.version_model
9
+ end
10
+
11
+ def sti_name
12
+ superclass.sti_name
13
+ end
14
+
15
+ def find_sti_class(type_name)
16
+ superclass.send(:find_sti_class, type_name).history_model
17
+ end
18
+
19
+ def finder_needs_type_condition?
20
+ superclass.finder_needs_type_condition?
21
+ end
22
+ end
23
+
24
+ included do
25
+ include Querying
26
+
27
+ if include?(SystemVersioned)
28
+ self.table_name = history_table_name
29
+ self.primary_key = Array(primary_key) + [:system_period]
30
+ end
31
+
32
+ self.time_dimensions = time_dimensions + [:system_period]
33
+
34
+ reflect_on_all_associations.each do |reflection|
35
+ next if reflection.scope&.temporal_scope?
36
+
37
+ send(
38
+ reflection.macro,
39
+ reflection.name,
40
+ reflection.scope,
41
+ **reflection.options.merge(temporal: true)
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,45 @@
1
+ module ActiveRecord::Temporal
2
+ module SystemVersioning
3
+ module HistoryModelNamespace
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def const_missing(model_name)
8
+ model = join(@root, model_name).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
+ history_model = Class.new(model) do
17
+ include HistoryModel
18
+ end
19
+
20
+ const_set(model_name, history_model)
21
+ end
22
+
23
+ def namespace(name, &block)
24
+ new_namespace = Module.new do
25
+ include HistoryModelNamespace
26
+ end
27
+
28
+ const_set(name, new_namespace)
29
+
30
+ new_namespace.root(join(@root, name))
31
+
32
+ new_namespace.instance_eval(&block) if block
33
+ end
34
+
35
+ def root(name)
36
+ @root = name
37
+ end
38
+
39
+ def join(base, name)
40
+ [base, name].compact.join("::")
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,29 @@
1
+ module ActiveRecord::Temporal
2
+ module SystemVersioning
3
+ module HistoryModels
4
+ class Error < StandardError; end
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ delegate :at_time, :as_of, to: :history
10
+
11
+ def history_model
12
+ raise Error, "abstract classes cannot have a history model" if abstract_class?
13
+
14
+ [history_model_namespace, name].join("::").constantize
15
+ end
16
+
17
+ def history
18
+ ActiveRecord::Relation.create(history_model)
19
+ end
20
+
21
+ private
22
+
23
+ def history_model_namespace
24
+ History
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ module ActiveRecord::Temporal
2
+ module SystemVersioning
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
+ system_versioning = options.delete(:system_versioning)
13
+
14
+ if system_versioning
15
+ create_table_with_system_versioning(
16
+ table_name, id:, primary_key:, force:, **options, &block
17
+ )
18
+ else
19
+ super
20
+ end
21
+ end
22
+
23
+ def drop_table(*table_names, **options)
24
+ system_versioning = options.delete(:system_versioning)
25
+
26
+ if system_versioning
27
+ drop_table_with_system_versioning(*table_names, **options)
28
+ else
29
+ super
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -52,7 +52,7 @@ module ActiveRecord::Temporal
52
52
 
53
53
  def visit_UpdateHookDefinition(o)
54
54
  column_names = o.columns.map { |c| quote_column_name(c) }
55
- primary_key_quoted = o.primary_key.map { |c| quote_column_name(c) }
55
+ primary_key_quoted = Array(o.primary_key).map { |c| quote_column_name(c) }
56
56
  fields = column_names.join(", ")
57
57
  values = column_names.map { |c| "NEW.#{c}" }.join(", ")
58
58
  update_pk_predicates = primary_key_quoted.map { |c| "#{c} = OLD.#{c}" }.join(" AND ")
@@ -94,7 +94,7 @@ module ActiveRecord::Temporal
94
94
  end
95
95
 
96
96
  def visit_DeleteHookDefinition(o)
97
- primary_key_quoted = o.primary_key.map { |c| quote_column_name(c) }
97
+ primary_key_quoted = Array(o.primary_key).map { |c| quote_column_name(c) }
98
98
  function_name = versioning_function_name(o.source_table, :delete)
99
99
  pk_predicates = primary_key_quoted.map { |c| "#{c} = OLD.#{c}" }.join(" AND ")
100
100
  metadata = {
@@ -1,32 +1,96 @@
1
1
  module ActiveRecord::Temporal
2
2
  module SystemVersioning
3
3
  module SchemaStatements
4
+ def create_table_with_system_versioning(table_name, **options, &block)
5
+ create_table(table_name, **options, &block)
6
+
7
+ source_pk = Array(primary_key(table_name))
8
+ history_options = options.merge(primary_key: source_pk + ["system_period"])
9
+
10
+ exclusion_constraint_expression = source_pk.map do |col|
11
+ "#{col} WITH ="
12
+ end.join(", ") + ", system_period WITH &&"
13
+
14
+ create_table("#{table_name}_history", **history_options) do |t|
15
+ columns(table_name).each do |column|
16
+ t.send(
17
+ column.type,
18
+ column.name,
19
+ comment: column.comment,
20
+ collation: column.collation,
21
+ default: nil,
22
+ limit: column.limit,
23
+ null: column.null,
24
+ precision: column.precision,
25
+ scale: column.scale
26
+ )
27
+ end
28
+
29
+ t.tstzrange :system_period, null: false
30
+ t.exclusion_constraint exclusion_constraint_expression, using: :gist
31
+ end
32
+
33
+ create_versioning_hook table_name,
34
+ "#{table_name}_history",
35
+ columns: :all,
36
+ primary_key: source_pk
37
+ end
38
+
39
+ def drop_table_with_system_versioning(*table_names, **options)
40
+ table_names.each do |table_name|
41
+ history_table_name = "#{table_name}_history"
42
+
43
+ drop_table(table_name, **options)
44
+ drop_table(history_table_name, **options)
45
+ drop_versioning_hook(table_name, history_table_name, **options.slice(:columns, :primary_key, :if_exists))
46
+ end
47
+ end
48
+
4
49
  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))
50
+ options.assert_valid_keys(:columns, :primary_key)
51
+
52
+ column_names = if (columns = options.fetch(:columns)) == :all
53
+ columns(source_table).map(&:name)
54
+ else
55
+ Array(columns).map(&:to_s)
56
+ end
57
+
58
+ primary_key = options.fetch(:primary_key, :id)
59
+
60
+ primary_key = if primary_key.is_a?(Array) && primary_key.length == 1
61
+ primary_key.first
62
+ else
63
+ primary_key
64
+ end
7
65
 
8
66
  ensure_table_exists!(source_table)
9
67
  ensure_table_exists!(history_table)
10
- ensure_columns_match!(source_table, history_table, columns)
11
- ensure_columns_exists!(source_table, primary_key)
68
+ ensure_columns_match!(source_table, history_table, column_names)
69
+ ensure_columns_exists!(source_table, Array(primary_key))
12
70
 
13
71
  schema_creation = SchemaCreation.new(self)
14
72
 
15
73
  hook_definition = VersioningHookDefinition.new(
16
74
  source_table,
17
75
  history_table,
18
- columns: columns,
76
+ columns: column_names,
19
77
  primary_key: primary_key
20
78
  )
21
79
 
22
80
  execute schema_creation.accept(hook_definition)
23
81
  end
24
82
 
25
- def drop_versioning_hook(source_table, history_table, columns: nil)
83
+ def drop_versioning_hook(source_table, history_table, **options)
84
+ options.assert_valid_keys(:columns, :primary_key, :if_exists)
85
+
26
86
  %i[insert update delete].each do |verb|
27
87
  function_name = versioning_function_name(source_table, verb)
28
88
 
29
- execute "DROP FUNCTION #{function_name}() CASCADE"
89
+ sql = "DROP FUNCTION"
90
+ sql << " IF EXISTS" if options[:if_exists]
91
+ sql << " #{function_name}() CASCADE"
92
+
93
+ execute sql
30
94
  end
31
95
  end
32
96
 
@@ -56,6 +120,8 @@ module ActiveRecord::Temporal
56
120
  end
57
121
 
58
122
  def change_versioning_hook(source_table, history_table, options)
123
+ options.assert_valid_keys(:add_columns, :remove_columns)
124
+
59
125
  add_columns = (options[:add_columns] || []).map(&:to_s)
60
126
  remove_columns = (options[:remove_columns] || []).map(&:to_s)
61
127
 
@@ -71,7 +137,10 @@ module ActiveRecord::Temporal
71
137
 
72
138
  new_columns = hook_definition.columns + add_columns - remove_columns
73
139
 
74
- create_versioning_hook(source_table, history_table, columns: new_columns)
140
+ create_versioning_hook source_table,
141
+ history_table,
142
+ columns: new_columns,
143
+ primary_key: hook_definition.primary_key
75
144
  end
76
145
 
77
146
  def history_table(source_table)
@@ -89,6 +158,9 @@ module ActiveRecord::Temporal
89
158
 
90
159
  private
91
160
 
161
+ def validate_create_versioning_hook_options!(options)
162
+ end
163
+
92
164
  def ensure_table_exists!(table_name)
93
165
  return if table_exists?(table_name)
94
166
 
@@ -0,0 +1,13 @@
1
+ module ActiveRecord::Temporal
2
+ module SystemVersioning
3
+ module SystemVersioned
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def history_table_name
8
+ table_name + "_history" if table_name
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -2,25 +2,13 @@ module ActiveRecord::Temporal
2
2
  module SystemVersioning
3
3
  extend ActiveSupport::Concern
4
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
5
+ included do
6
+ include HistoryModels
7
+ end
17
8
 
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
9
+ class_methods do
10
+ def system_versioned
11
+ include SystemVersioned
24
12
  end
25
13
  end
26
14
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveRecord::Temporal
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end