activerecord-materialized 0.1.1 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +138 -323
  4. data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
  5. data/lib/activerecord/materialized/async_refresher.rb +18 -27
  6. data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
  7. data/lib/activerecord/materialized/change_source.rb +25 -0
  8. data/lib/activerecord/materialized/cold_read.rb +1 -10
  9. data/lib/activerecord/materialized/column_type_inference.rb +167 -0
  10. data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
  11. data/lib/activerecord/materialized/configuration.rb +104 -29
  12. data/lib/activerecord/materialized/connection_routing.rb +29 -0
  13. data/lib/activerecord/materialized/data_verification_result.rb +41 -0
  14. data/lib/activerecord/materialized/data_verifier.rb +162 -0
  15. data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
  16. data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
  17. data/lib/activerecord/materialized/dependency_registry.rb +43 -35
  18. data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
  19. data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
  20. data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
  21. data/lib/activerecord/materialized/instrumentation.rb +102 -0
  22. data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
  23. data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
  24. data/lib/activerecord/materialized/maintenance_store.rb +58 -21
  25. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
  26. data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
  27. data/lib/activerecord/materialized/metadata/schema.rb +19 -41
  28. data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
  29. data/lib/activerecord/materialized/metadata.rb +41 -25
  30. data/lib/activerecord/materialized/metadata_record.rb +2 -16
  31. data/lib/activerecord/materialized/migration_builder.rb +8 -8
  32. data/lib/activerecord/materialized/module_api.rb +219 -22
  33. data/lib/activerecord/materialized/partition_filter.rb +45 -0
  34. data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
  35. data/lib/activerecord/materialized/partition_record.rb +2 -16
  36. data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
  37. data/lib/activerecord/materialized/partition_state.rb +47 -46
  38. data/lib/activerecord/materialized/query_expressions.rb +0 -12
  39. data/lib/activerecord/materialized/railtie.rb +15 -3
  40. data/lib/activerecord/materialized/read_router.rb +51 -0
  41. data/lib/activerecord/materialized/reconcile_job.rb +22 -0
  42. data/lib/activerecord/materialized/reconcile_result.rb +36 -0
  43. data/lib/activerecord/materialized/reconciler.rb +79 -0
  44. data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
  45. data/lib/activerecord/materialized/refresh_job.rb +0 -4
  46. data/lib/activerecord/materialized/refresh_result.rb +2 -10
  47. data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
  48. data/lib/activerecord/materialized/refresher.rb +35 -50
  49. data/lib/activerecord/materialized/registry.rb +26 -16
  50. data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
  51. data/lib/activerecord/materialized/schema_verifier.rb +0 -9
  52. data/lib/activerecord/materialized/source_watermark.rb +93 -0
  53. data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
  54. data/lib/activerecord/materialized/summary_delta.rb +1 -17
  55. data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
  56. data/lib/activerecord/materialized/table_model_registry.rb +1 -8
  57. data/lib/activerecord/materialized/table_swap.rb +106 -0
  58. data/lib/activerecord/materialized/tasks.rb +46 -38
  59. data/lib/activerecord/materialized/version.rb +1 -2
  60. data/lib/activerecord/materialized/view.rb +11 -22
  61. data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
  62. data/lib/activerecord/materialized/view_definition.rb +76 -65
  63. data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
  64. data/lib/activerecord/materialized/view_loader.rb +49 -0
  65. data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
  66. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
  67. data/lib/activerecord/materialized/write_change.rb +75 -26
  68. data/lib/activerecord/materialized/write_maintenance.rb +73 -0
  69. data/lib/activerecord/materialized/write_outbox.rb +345 -0
  70. data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
  71. data/lib/activerecord/materialized.rb +27 -6
  72. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
  73. data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
  74. data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
  75. data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
  76. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
  77. data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
  78. data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
  79. metadata +27 -18
  80. data/lib/activerecord/materialized/type_reexports.rb +0 -14
  81. data/lib/activerecord/materialized/view_class.rb +0 -8
  82. data/lib/activerecord_materialized_types.rb +0 -18
@@ -1,29 +1,24 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # Defines the `materialized:*` rake tasks (refresh_all, refresh_stale, rebuild, verify, warm_up).
7
6
  module Tasks
8
- extend T::Sig
7
+ DEFINITIONS = {
8
+ refresh_all: "Refresh all registered materialized views",
9
+ refresh_stale: "Refresh stale materialized views",
10
+ enqueue_refreshes: "Enqueue a background RefreshJob per stale view (fleet fan-out; run from one owner)",
11
+ rebuild: "Rebuild (fully materialize) all registered materialized views",
12
+ verify: "Verify materialized view cache tables match their source relations",
13
+ audit: "Verify materialized view contents against their source relations (data drift)",
14
+ reconcile: "Reconcile stale materialized views: verify contents and repair drift (scoped)",
15
+ enqueue_reconciles: "Enqueue a background ReconcileJob per stale view (fleet fan-out; run from one owner)",
16
+ warm_up: "Materialize each view's configured warm_up partitions"
17
+ }.freeze
9
18
 
10
- DEFINITIONS = T.let(
11
- {
12
- refresh_all: "Refresh all registered materialized views",
13
- refresh_stale: "Refresh stale materialized views",
14
- rebuild: "Rebuild (fully materialize) all registered materialized views",
15
- verify: "Verify materialized view cache tables match their source relations",
16
- warm_up: "Materialize each view's configured warm_up partitions"
17
- }.freeze,
18
- T::Hash[Symbol, String]
19
- )
20
-
21
- sig { void }
22
19
  def self.define!
23
- application = T.let(T.unsafe(::Rake.application), T.untyped)
20
+ application = ::Rake.application
24
21
  application.instance_eval do
25
- T.bind(self, T.untyped)
26
-
27
22
  namespace :materialized do
28
23
  DEFINITIONS.each do |task_name, description|
29
24
  desc description
@@ -33,46 +28,59 @@ module ActiveRecord
33
28
  end
34
29
  end
35
30
 
36
- sig { params(task_name: Symbol).void }
31
+ # Each task name maps by convention to its `run_<task_name>!` module method.
37
32
  def self.run!(task_name)
38
- case task_name
39
- when :refresh_all then run_refresh_all!
40
- when :refresh_stale then run_refresh_stale!
41
- when :rebuild then run_rebuild_all!
42
- when :verify then run_verify!
43
- when :warm_up then run_warm_up_all!
44
- end
33
+ public_send(:"run_#{task_name}!")
45
34
  end
46
35
 
47
- sig { void }
48
36
  def self.run_refresh_all!
49
37
  Registry.refresh_all!
50
- T.unsafe(Rails).logger.debug { "Refreshed #{Registry.all.size} materialized view(s)." }
38
+ Rails.logger.debug { "Refreshed #{Registry.all.size} materialized view(s)." }
51
39
  end
52
40
 
53
- sig { void }
54
41
  def self.run_refresh_stale!
55
- stale = Registry.all.select(&:stale?)
42
+ stale = Registry.stale_views
56
43
  stale.each(&:refresh!)
57
- T.unsafe(Rails).logger.debug { "Refreshed #{stale.size} stale materialized view(s)." }
44
+ Rails.logger.debug { "Refreshed #{stale.size} stale materialized view(s)." }
58
45
  end
59
46
 
60
- sig { void }
61
- def self.run_rebuild_all!
47
+ def self.run_rebuild!
62
48
  Registry.rebuild_all!
63
- T.unsafe(Rails).logger.debug { "Rebuilt #{Registry.all.size} materialized view(s)." }
49
+ Rails.logger.debug { "Rebuilt #{Registry.all.size} materialized view(s)." }
64
50
  end
65
51
 
66
- sig { void }
67
52
  def self.run_verify!
68
53
  ActiveRecord::Materialized.verify_schema!
69
- T.unsafe(Rails).logger.debug { "Verified #{Registry.all.size} materialized view schema(s)." }
54
+ Rails.logger.debug { "Verified #{Registry.all.size} materialized view schema(s)." }
55
+ end
56
+
57
+ def self.run_audit!
58
+ ActiveRecord::Materialized.verify_data!
59
+ Rails.logger.debug { "Audited data for #{Registry.all.size} materialized view(s)." }
60
+ end
61
+
62
+ def self.run_reconcile!
63
+ results = ActiveRecord::Materialized.reconcile_stale!
64
+ repaired = results.sum(&:repaired_partition_count)
65
+ failed = results.count(&:failed?)
66
+ Rails.logger.debug do
67
+ "Reconciled #{results.size} stale view(s); repaired #{repaired} partition(s); #{failed} failed."
68
+ end
69
+ end
70
+
71
+ def self.run_enqueue_refreshes!
72
+ keys = ActiveRecord::Materialized.enqueue_stale_refreshes!
73
+ Rails.logger.debug { "Enqueued #{keys.size} refresh job(s) for stale view(s)." }
74
+ end
75
+
76
+ def self.run_enqueue_reconciles!
77
+ keys = ActiveRecord::Materialized.enqueue_stale_reconciles!
78
+ Rails.logger.debug { "Enqueued #{keys.size} reconcile job(s) for stale view(s)." }
70
79
  end
71
80
 
72
- sig { void }
73
- def self.run_warm_up_all!
81
+ def self.run_warm_up!
74
82
  Registry.warm_up_all!
75
- T.unsafe(Rails).logger.debug { "Warmed up #{Registry.all.size} materialized view(s)." }
83
+ Rails.logger.debug { "Warmed up #{Registry.all.size} materialized view(s)." }
76
84
  end
77
85
  end
78
86
  end
@@ -1,9 +1,8 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # The gem version.
7
- VERSION = "0.1.1"
6
+ VERSION = "0.2.0"
8
7
  end
9
8
  end
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
@@ -36,7 +35,6 @@ module ActiveRecord
36
35
  # @see ViewIncrementalClassMethods::ClassMethods incremental-maintenance configuration
37
36
  # @see ViewQueryAccessClassMethods::ClassMethods +rebuild!+, +refresh!+, +materialized?+, …
38
37
  class View < ::ActiveRecord::Base
39
- extend T::Sig
40
38
  include RefreshCallbacks
41
39
  include ViewConfigurationClassMethods
42
40
  include ViewQueryAccessClassMethods
@@ -44,34 +42,25 @@ module ActiveRecord
44
42
  self.abstract_class = true
45
43
 
46
44
  class << self
47
- extend T::Sig
45
+ @source_definition = nil
46
+ @max_staleness_setting = nil
47
+ @dependency_tables = nil
48
+ @refresh_strategy = nil
49
+ @refresh_debounce = nil
50
+ @refresh_mode = nil
51
+ @incremental_source_definition = nil
52
+ @incremental_key_columns = nil
53
+ @table_name = nil
48
54
 
49
- @source_definition = T.let(nil, T.nilable(SourceDefinition))
50
- @max_staleness_setting = T.let(nil, T.nilable(T.any(StalenessDuration, Proc)))
51
- @dependency_tables = T.let(nil, T.nilable(T::Array[String]))
52
- @refresh_strategy = T.let(nil, T.nilable(Symbol))
53
- @refresh_debounce = T.let(nil, T.nilable(DebounceInterval))
54
- @refresh_mode = T.let(nil, T.nilable(RefreshMode))
55
- @incremental_source_definition = T.let(nil, T.nilable(SourceDefinition))
56
- @incremental_key_columns = T.let(nil, T.nilable(T::Array[String]))
57
- @table_name = T.let(nil, T.nilable(String))
55
+ attr_reader :source_definition, :max_staleness_setting
58
56
 
59
- sig { returns(T.nilable(SourceDefinition)) }
60
- attr_reader :source_definition
61
-
62
- sig { returns(T.nilable(T.any(StalenessDuration, Proc))) }
63
- attr_reader :max_staleness_setting
64
-
65
- sig { returns(T::Array[String]) }
66
57
  def dependency_tables
67
- tables = T.let(T.unsafe(self).instance_variable_get(:@dependency_tables), T.nilable(T::Array[String]))
58
+ tables = instance_variable_get(:@dependency_tables)
68
59
  tables.nil? ? [] : tables
69
60
  end
70
61
  end
71
62
 
72
- sig { returns(T::Boolean) }
73
63
  def stale?
74
- T.bind(self, View)
75
64
  self.class.stale?
76
65
  end
77
66
  end
@@ -1,62 +1,61 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
5
  # The `materialized_from` / `depends_on` DSL and source/metadata accessors mixed into every {View}.
7
6
  module ViewConfigurationClassMethods
8
- extend T::Sig
9
- extend T::Helpers
10
-
11
- sig { params(base: T.class_of(View)).void }
12
7
  def self.included(base)
13
8
  base.extend(ClassMethods)
14
9
  end
15
10
 
16
11
  # The configuration DSL methods available on a {View} subclass.
17
12
  module ClassMethods
18
- extend T::Sig
19
13
  include ViewIncrementalClassMethods::ClassMethods
20
14
  include ViewRefreshPolicyClassMethods::ClassMethods
21
15
 
22
- sig { returns(T.class_of(View)) }
16
+ # Per-subclass DSL ivars reset to nil on inheritance so a subclass never
17
+ # inherits another view's configuration. (`@dependency_tables` resets to
18
+ # an empty list instead — see {inherited}.)
19
+ NIL_RESET_IVARS = %i[
20
+ @refresh_strategy @refresh_debounce @refresh_mode @incremental_source_definition
21
+ @incremental_key_columns @partition_key_resolvers @cold_read_strategy @change_source
22
+ @warm_up_definition
23
+ ].freeze
24
+
23
25
  def view_class
24
- T.cast(self, T.class_of(View))
26
+ self
25
27
  end
26
28
 
27
- sig { params(subclass: T.class_of(View)).void }
28
29
  def inherited(subclass)
29
30
  super
30
- T.unsafe(subclass).instance_variable_set(:@dependency_tables, [])
31
- T.unsafe(subclass).instance_variable_set(:@refresh_strategy, nil)
32
- T.unsafe(subclass).instance_variable_set(:@refresh_debounce, nil)
33
- T.unsafe(subclass).instance_variable_set(:@refresh_mode, nil)
34
- T.unsafe(subclass).instance_variable_set(:@incremental_source_definition, nil)
35
- T.unsafe(subclass).instance_variable_set(:@incremental_key_columns, nil)
36
- T.unsafe(subclass).instance_variable_set(:@cold_read_strategy, nil)
37
- T.unsafe(subclass).instance_variable_set(:@warm_up_definition, nil)
31
+ subclass.instance_variable_set(:@dependency_tables, [])
32
+ NIL_RESET_IVARS.each { |ivar| subclass.instance_variable_set(ivar, nil) }
38
33
  end
39
34
 
40
- sig { returns(String) }
41
35
  def view_key
42
- return T.must(view_class.name).underscore if view_class.name.present?
36
+ return view_class.name.underscore if view_class.name.present?
43
37
 
44
- table = T.let(T.unsafe(view_class).instance_variable_get(:@table_name), T.nilable(String))
38
+ table = view_class.instance_variable_get(:@table_name)
45
39
  table.presence || "anonymous_view_#{view_class.object_id}"
46
40
  end
47
41
 
48
- sig { params(block: T.proc.returns(::ActiveRecord::Relation)).void }
42
+ # Declares the source relation this view materializes and registers the view unless it is abstract.
43
+ #
44
+ # @yieldreturn [ActiveRecord::Relation] the query whose result set is cached in the view's table
45
+ # @return [void]
49
46
  def materialized_from(&block)
50
- @source_definition = T.let(block, T.nilable(SourceDefinition))
47
+ @source_definition = block
51
48
  Registry.register(view_class) unless view_class.abstract_class?
52
49
  end
53
50
 
54
- sig { params(tables: T.any(Symbol, String, T.class_of(::ActiveRecord::Base))).void }
51
+ # Declares the dependency tables whose committed writes trigger maintenance of this view.
52
+ #
53
+ # @param tables [Array<Symbol, String, Class<ActiveRecord::Base>>] dependency tables this view reads from
54
+ # @return [void]
55
55
  def depends_on(*tables)
56
56
  DependencyRegistry.register(view_class, tables)
57
57
  end
58
58
 
59
- sig { returns(::ActiveRecord::Relation) }
60
59
  def resolved_source
61
60
  resolve_source_definition(
62
61
  @source_definition,
@@ -64,20 +63,12 @@ module ActiveRecord
64
63
  )
65
64
  end
66
65
 
67
- sig { returns(Metadata) }
68
66
  def metadata
69
- @metadata = T.let(@metadata, T.nilable(ActiveRecord::Materialized::Metadata))
70
67
  @metadata ||= ActiveRecord::Materialized::Metadata.new(view_class)
71
68
  end
72
69
 
73
70
  private
74
71
 
75
- sig do
76
- params(
77
- definition: T.nilable(SourceDefinition),
78
- empty_message: String
79
- ).returns(::ActiveRecord::Relation)
80
- end
81
72
  def resolve_source_definition(definition, empty_message)
82
73
  source = coerce_source(definition)
83
74
  Kernel.raise ArgumentError, empty_message if source.nil?
@@ -88,7 +79,6 @@ module ActiveRecord
88
79
  source
89
80
  end
90
81
 
91
- sig { params(definition: T.nilable(SourceDefinition)).returns(T.untyped) }
92
82
  def coerce_source(definition)
93
83
  source = definition
94
84
  return source unless source.is_a?(Proc)
@@ -96,8 +86,6 @@ module ActiveRecord
96
86
  source.call
97
87
  end
98
88
  end
99
-
100
- mixes_in_class_methods ClassMethods
101
89
  end
102
90
  end
103
91
  end
@@ -1,131 +1,142 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module Materialized
6
- # Inspects a source relation for its GROUP BY maintenance keys and builds partition scopes.
5
+ # Inspects a source relation for its partition (maintenance) keys and builds partition scopes.
6
+ # The keys are the relation's GROUP BY columns, or — for a `SELECT DISTINCT a, b` with no GROUP BY
7
+ # and no aggregate — its projected columns, since a distinct lookup partitions exactly like a
8
+ # GROUP BY of those columns.
7
9
  #
8
10
  # @api private
9
11
  class ViewDefinition
10
- extend T::Sig
11
-
12
- sig do
13
- params(
14
- source: ::ActiveRecord::Relation,
15
- explicit_group_keys: T.nilable(T::Array[String])
16
- ).void
17
- end
18
12
  def initialize(source, explicit_group_keys: nil)
19
13
  @source = source
20
14
  @explicit_group_keys = explicit_group_keys
21
15
  end
22
16
 
23
- sig { returns(T::Boolean) }
24
17
  def incrementally_maintainable?
25
18
  group_key_columns.any?
26
19
  end
27
20
 
28
- sig { returns(T::Array[String]) }
29
21
  def group_key_columns
30
- @group_key_columns = T.let(@group_key_columns, T.nilable(T::Array[String]))
31
22
  @group_key_columns ||= resolve_group_key_columns
32
23
  end
33
24
 
34
25
  # Restrict a model/cache table to the given partitions. The partition
35
26
  # columns are real columns on `model`, so qualify them to its own table.
36
- sig do
37
- params(
38
- model: T.class_of(::ActiveRecord::Base),
39
- key_tuples: T::Array[T::Array[T.untyped]]
40
- ).returns(::ActiveRecord::Relation)
41
- end
42
27
  def partition_scope_on(model, key_tuples)
43
28
  validate_partition_keys!(key_tuples)
44
- attributes = group_key_columns.map { |column| T.unsafe(model).arel_table[column] }
45
- filter_partitions(model.unscoped, attributes, key_tuples)
29
+ # On the cache/model table the key is the projected column, so a qualified
30
+ # GROUP BY name (e.g. "authors.country") maps to the bare column ("country").
31
+ attributes = group_key_columns.map { |column| model.arel_table[unqualified(column)] }
32
+ PartitionFilter.new(attributes, key_tuples).apply(model.unscoped)
46
33
  end
47
34
 
48
35
  # Restrict the source relation to the given partitions. Qualify each key to
49
36
  # its GROUP BY attribute's own table, which may be a joined table (e.g.
50
37
  # `name.gender`) rather than the source's base table.
51
- sig { params(key_tuples: T::Array[T::Array[T.untyped]]).returns(::ActiveRecord::Relation) }
52
38
  def partition_scope(key_tuples)
53
39
  validate_partition_keys!(key_tuples)
54
- base = T.unsafe(source).klass.arel_table
55
- attributes = group_key_columns.map { |column| group_attributes[column] || base[column] }
56
- filter_partitions(source, attributes, key_tuples)
40
+ base = source.klass.arel_table
41
+ attributes = group_key_columns.map { |column| source_attribute(column, base) }
42
+ PartitionFilter.new(attributes, key_tuples).apply(source)
57
43
  end
58
44
 
59
45
  private
60
46
 
61
- sig { returns(::ActiveRecord::Relation) }
47
+ # The Arel attribute for a GROUP BY key on the source side: the captured Arel
48
+ # attribute if the key was given as one; otherwise a qualified string like
49
+ # "authors.country" builds that table's attribute, and a bare name uses the
50
+ # source's base table.
51
+ def source_attribute(column, base)
52
+ return group_attributes[column] if group_attributes.key?(column)
53
+
54
+ table, separator, name = column.rpartition(".")
55
+ separator.empty? ? base[column] : ::Arel::Table.new(table)[name]
56
+ end
57
+
58
+ # The bare column name, dropping any "table." qualifier.
59
+ def unqualified(column)
60
+ column.rpartition(".").last
61
+ end
62
+
62
63
  attr_reader :source
63
64
 
64
- sig { returns(T::Array[String]) }
65
+ # A bare or single-dot-qualified SQL identifier — "category" or
66
+ # "orders.region". Used to tell a plain key column in a DISTINCT projection
67
+ # from an expression (parens, star, spaces, operators), so a raw SQL string
68
+ # in the SELECT list is never mistaken for a partition key.
69
+ PLAIN_COLUMN_PATTERN = /\A\w+(?:\.\w+)?\z/
70
+ private_constant :PLAIN_COLUMN_PATTERN
71
+
65
72
  def resolve_group_key_columns
66
73
  return @explicit_group_keys if @explicit_group_keys&.any?
67
74
 
68
75
  relation_group_columns
69
76
  end
70
77
 
71
- sig { returns(T::Array[String]) }
72
78
  def relation_group_columns
73
- source.group_values.filter_map { |group_value| group_column_name(group_value) }
79
+ key_value_nodes.filter_map { |node| group_column_name(node) }
80
+ end
81
+
82
+ # The nodes that identify a partition: the GROUP BY values, or — for a
83
+ # DISTINCT projection of plain key columns with no GROUP BY — the projected
84
+ # columns themselves. `SELECT DISTINCT a, b` partitions identically to
85
+ # `GROUP BY a, b` (no aggregates), so a distinct lookup is maintained
86
+ # incrementally through the same scoped-recompute path. Anything else (an
87
+ # aggregate in the projection, `DISTINCT *`, no distinct) yields no keys and
88
+ # falls back to full refresh.
89
+ def key_value_nodes
90
+ return source.group_values if source.group_values.any?
91
+
92
+ distinct_key_projection? ? source.select_values : []
93
+ end
94
+
95
+ def distinct_key_projection?
96
+ return false unless source.distinct_value && source.group_values.empty?
97
+
98
+ nodes = source.select_values
99
+ nodes.any? && nodes.all? { |node| plain_key_column?(node) }
100
+ end
101
+
102
+ # Whether a projected node names a plain key column (safe as a DISTINCT
103
+ # partition key) rather than an aggregate or expression. A Symbol or Arel
104
+ # attribute is always a plain column; a String only when it is a bare or
105
+ # table-qualified identifier (never "COUNT(*)", "a + b", etc.).
106
+ def plain_key_column?(node)
107
+ case node
108
+ when Symbol, ::Arel::Attributes::Attribute
109
+ true
110
+ when String
111
+ node.match?(PLAIN_COLUMN_PATTERN)
112
+ else
113
+ false
114
+ end
74
115
  end
75
116
 
76
- sig { params(group_value: T.untyped).returns(T.nilable(String)) }
77
117
  def group_column_name(group_value)
78
118
  case group_value
79
119
  when String, Symbol
80
120
  group_value.to_s
81
121
  when ::Arel::Attributes::Attribute
82
- T.unsafe(group_value).name.to_s
122
+ group_value.name.to_s
83
123
  else
84
124
  group_value.to_s if group_value.respond_to?(:to_s)
85
125
  end
86
126
  end
87
127
 
88
- sig { params(key_tuples: T::Array[T::Array[T.untyped]]).void }
89
128
  def validate_partition_keys!(key_tuples)
90
129
  raise ArgumentError, "scoped maintenance requires GROUP BY keys" unless incrementally_maintainable?
91
130
  raise ArgumentError, "scoped maintenance requires at least one partition key" if key_tuples.empty?
92
131
  end
93
132
 
94
- # GROUP BY attributes keyed by name; an Arel attribute carries its real
133
+ # Partition-key attributes keyed by name; an Arel attribute carries its real
95
134
  # table (e.g. a joined `name.gender`) that the bare base table column lacks.
96
- sig { returns(T::Hash[String, T.untyped]) }
135
+ # Reads the same nodes as the key columns, so a DISTINCT projection's joined
136
+ # key attribute (e.g. `orders.region`) qualifies to its own table too.
97
137
  def group_attributes
98
- @group_attributes = T.let(@group_attributes, T.nilable(T::Hash[String, T.untyped]))
99
- @group_attributes ||= T.unsafe(source).group_values.each_with_object({}) do |value, map|
100
- map[T.unsafe(value).name.to_s] = value if value.is_a?(::Arel::Attributes::Attribute)
101
- end
102
- end
103
-
104
- sig do
105
- params(
106
- scope: ::ActiveRecord::Relation,
107
- attributes: T::Array[T.untyped],
108
- key_tuples: T::Array[T::Array[T.untyped]]
109
- ).returns(::ActiveRecord::Relation)
110
- end
111
- def filter_partitions(scope, attributes, key_tuples)
112
- return multi_partition_filter(scope, attributes, key_tuples) if attributes.size > 1
113
-
114
- scope.where(T.unsafe(attributes.fetch(0)).in(key_tuples.map(&:first)))
115
- end
116
-
117
- sig do
118
- params(
119
- scope: ::ActiveRecord::Relation, attributes: T::Array[T.untyped],
120
- key_tuples: T::Array[T::Array[T.untyped]]
121
- ).returns(::ActiveRecord::Relation)
122
- end
123
- def multi_partition_filter(scope, attributes, key_tuples)
124
- key_tuples.reduce(T.unsafe(nil)) do |merged_scope, tuple|
125
- branch = attributes.each_with_index.reduce(scope) do |relation, (attribute, index)|
126
- relation.where(T.unsafe(attribute).eq(tuple[index]))
127
- end
128
- merged_scope.nil? ? branch : merged_scope.or(branch)
138
+ @group_attributes ||= key_value_nodes.each_with_object({}) do |value, map|
139
+ map[value.name.to_s] = value if value.is_a?(::Arel::Attributes::Attribute)
129
140
  end
130
141
  end
131
142
  end