activerecord-materialized 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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE +21 -0
  4. data/README.md +526 -0
  5. data/lib/activerecord/materialized/aggregate_analysis.rb +132 -0
  6. data/lib/activerecord/materialized/async_refresher.rb +105 -0
  7. data/lib/activerecord/materialized/cache_table_schema.rb +67 -0
  8. data/lib/activerecord/materialized/cold_read.rb +60 -0
  9. data/lib/activerecord/materialized/configuration.rb +80 -0
  10. data/lib/activerecord/materialized/delta_maintainer.rb +74 -0
  11. data/lib/activerecord/materialized/dependency_registry.rb +107 -0
  12. data/lib/activerecord/materialized/dependency_trackable.rb +48 -0
  13. data/lib/activerecord/materialized/incremental_maintainer.rb +58 -0
  14. data/lib/activerecord/materialized/maintenance_delta.rb +82 -0
  15. data/lib/activerecord/materialized/maintenance_delta_builder.rb +62 -0
  16. data/lib/activerecord/materialized/maintenance_store.rb +82 -0
  17. data/lib/activerecord/materialized/metadata/maintenance_payload.rb +33 -0
  18. data/lib/activerecord/materialized/metadata/schema.rb +84 -0
  19. data/lib/activerecord/materialized/metadata/timestamps.rb +31 -0
  20. data/lib/activerecord/materialized/metadata.rb +138 -0
  21. data/lib/activerecord/materialized/metadata_record.rb +28 -0
  22. data/lib/activerecord/materialized/migration_builder.rb +38 -0
  23. data/lib/activerecord/materialized/module_api.rb +82 -0
  24. data/lib/activerecord/materialized/partition_record.rb +27 -0
  25. data/lib/activerecord/materialized/partition_state.rb +127 -0
  26. data/lib/activerecord/materialized/query_expressions.rb +83 -0
  27. data/lib/activerecord/materialized/railtie.rb +16 -0
  28. data/lib/activerecord/materialized/refresh_callbacks.rb +62 -0
  29. data/lib/activerecord/materialized/refresh_job.rb +22 -0
  30. data/lib/activerecord/materialized/refresh_result.rb +40 -0
  31. data/lib/activerecord/materialized/refresh_scheduler.rb +54 -0
  32. data/lib/activerecord/materialized/refresher.rb +139 -0
  33. data/lib/activerecord/materialized/registry.rb +74 -0
  34. data/lib/activerecord/materialized/relation_cache_writer.rb +137 -0
  35. data/lib/activerecord/materialized/schema_verifier.rb +64 -0
  36. data/lib/activerecord/materialized/summary_delta.rb +76 -0
  37. data/lib/activerecord/materialized/summary_delta_builder.rb +58 -0
  38. data/lib/activerecord/materialized/table_model_registry.rb +43 -0
  39. data/lib/activerecord/materialized/tasks.rb +79 -0
  40. data/lib/activerecord/materialized/type_reexports.rb +14 -0
  41. data/lib/activerecord/materialized/version.rb +9 -0
  42. data/lib/activerecord/materialized/view.rb +79 -0
  43. data/lib/activerecord/materialized/view_class.rb +8 -0
  44. data/lib/activerecord/materialized/view_configuration_class_methods.rb +103 -0
  45. data/lib/activerecord/materialized/view_definition.rb +133 -0
  46. data/lib/activerecord/materialized/view_incremental_class_methods.rb +142 -0
  47. data/lib/activerecord/materialized/view_query_access_class_methods.rb +160 -0
  48. data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +109 -0
  49. data/lib/activerecord/materialized/write_change.rb +69 -0
  50. data/lib/activerecord/materialized.rb +55 -0
  51. data/lib/activerecord_materialized_types.rb +18 -0
  52. data/lib/generators/activerecord_materialized/install/templates/README +55 -0
  53. data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +30 -0
  54. data/lib/generators/activerecord_materialized/install_generator.rb +32 -0
  55. data/lib/generators/activerecord_materialized/migration_generator.rb +51 -0
  56. data/lib/generators/activerecord_materialized/templates/materialized_view.rb.erb +17 -0
  57. data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +11 -0
  58. data/lib/generators/activerecord_materialized/view_generator.rb +18 -0
  59. metadata +162 -0
@@ -0,0 +1,132 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Decides whether a view can be maintained by applying signed deltas
7
+ # (summary-delta IVM) instead of re-aggregating a partition's base rows. True
8
+ # only for a single-table GROUP BY without HAVING whose aggregates are all
9
+ # distributive (SUM/COUNT/COUNT(*)) and which carries a trustworthy row count
10
+ # so emptied partitions can be detected; everything else falls back to scoped
11
+ # recompute, which is always correct.
12
+ class AggregateAnalysis
13
+ extend T::Sig
14
+
15
+ # One classified aggregate column from a view's projection.
16
+ #
17
+ # @api private
18
+ class Column < T::Struct
19
+ const :name, String
20
+ const :function, Symbol
21
+ const :attribute, T.nilable(String)
22
+ const :counts_rows, T::Boolean, default: false
23
+ end
24
+
25
+ sig { params(relation: ::ActiveRecord::Relation).void }
26
+ def initialize(relation)
27
+ @relation = relation
28
+ @aggregate_columns = T.let(nil, T.nilable(T::Array[Column]))
29
+ end
30
+
31
+ sig { returns(T::Array[Column]) }
32
+ def aggregate_columns
33
+ @aggregate_columns ||= T.unsafe(@relation).select_values.filter_map { |value| classify(value) }
34
+ end
35
+
36
+ sig { returns(T::Boolean) }
37
+ def delta_maintainable?
38
+ single_table? && grouped? && !having? && distributive_aggregates? && aggregate_columns.any?(&:counts_rows)
39
+ end
40
+
41
+ sig { returns(T::Boolean) }
42
+ def distributive_aggregates?
43
+ aggregate_columns.any? && aggregate_columns.all? { |column| distributive?(column) }
44
+ end
45
+
46
+ sig { returns(T.nilable(Column)) }
47
+ def row_count_column
48
+ aggregate_columns.find(&:counts_rows)
49
+ end
50
+
51
+ private
52
+
53
+ sig { params(value: T.untyped).returns(T.nilable(Column)) }
54
+ def classify(value)
55
+ return nil unless value.is_a?(::Arel::Nodes::As)
56
+
57
+ node = value.left
58
+ name = alias_name(value)
59
+ case node
60
+ when ::Arel::Nodes::Sum then Column.new(name: name, function: :sum, attribute: attribute_name(node))
61
+ when ::Arel::Nodes::Avg then Column.new(name: name, function: :avg, attribute: attribute_name(node))
62
+ when ::Arel::Nodes::Min then Column.new(name: name, function: :min, attribute: attribute_name(node))
63
+ when ::Arel::Nodes::Max then Column.new(name: name, function: :max, attribute: attribute_name(node))
64
+ when ::Arel::Nodes::Count then count_column(name, node)
65
+ else Column.new(name: name, function: :other)
66
+ end
67
+ end
68
+
69
+ sig { params(name: String, node: T.untyped).returns(Column) }
70
+ def count_column(name, node)
71
+ return Column.new(name: name, function: :count_distinct, attribute: attribute_name(node)) if node.distinct
72
+ return Column.new(name: name, function: :count_star, counts_rows: true) if star?(node)
73
+
74
+ attribute = attribute_name(node)
75
+ Column.new(name: name, function: :count, attribute: attribute, counts_rows: not_null_column?(attribute))
76
+ end
77
+
78
+ sig { params(column: Column).returns(T::Boolean) }
79
+ def distributive?(column)
80
+ case column.function
81
+ # A SUM over a nullable column can be NULL, which a zero delta can't
82
+ # distinguish from 0, so only NOT NULL sums are delta-maintainable.
83
+ when :sum then !column.attribute.nil? && not_null_column?(column.attribute)
84
+ when :count then !column.attribute.nil?
85
+ when :count_star then true
86
+ else false
87
+ end
88
+ end
89
+
90
+ sig { params(node: T.untyped).returns(T.nilable(String)) }
91
+ def attribute_name(node)
92
+ inner = node.expressions.first
93
+ inner.is_a?(::Arel::Attributes::Attribute) ? T.unsafe(inner).name.to_s : nil
94
+ end
95
+
96
+ sig { params(node: T.untyped).returns(T::Boolean) }
97
+ def star?(node)
98
+ inner = node.expressions.first
99
+ !!(inner.is_a?(::Arel::Nodes::SqlLiteral) && inner.to_s == "*")
100
+ end
101
+
102
+ sig { params(value: ::Arel::Nodes::As).returns(String) }
103
+ def alias_name(value)
104
+ right = T.unsafe(value).right
105
+ right.respond_to?(:name) ? right.name.to_s : right.to_s
106
+ end
107
+
108
+ sig { params(attribute: T.nilable(String)).returns(T::Boolean) }
109
+ def not_null_column?(attribute)
110
+ return false if attribute.nil?
111
+
112
+ column = T.unsafe(@relation).klass.columns_hash[attribute]
113
+ !column.nil? && !column.null
114
+ end
115
+
116
+ sig { returns(T::Boolean) }
117
+ def single_table?
118
+ T.unsafe(@relation).joins_values.empty? && T.unsafe(@relation).from_clause.value.nil?
119
+ end
120
+
121
+ sig { returns(T::Boolean) }
122
+ def grouped?
123
+ T.unsafe(@relation).group_values.any?
124
+ end
125
+
126
+ sig { returns(T::Boolean) }
127
+ def having?
128
+ !T.unsafe(@relation).having_clause.empty?
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,105 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # In-process, debounced background refresher — the default `:async` dispatcher.
7
+ #
8
+ # @api private
9
+ class AsyncRefresher
10
+ class << self
11
+ extend T::Sig
12
+
13
+ sig { params(view_class: ViewClass).void }
14
+ def enqueue(view_class)
15
+ interval = view_class.resolved_refresh_debounce
16
+
17
+ mutex.synchronize do
18
+ pending[view_class.view_key] = view_class
19
+ schedule_unlocked(interval)
20
+ end
21
+ end
22
+
23
+ sig { void }
24
+ def flush!
25
+ mutex.synchronize do
26
+ cancel_timer_unlocked
27
+ drain_pending_unlocked
28
+ end
29
+ end
30
+
31
+ sig { returns(Integer) }
32
+ def pending_count
33
+ mutex.synchronize { pending.size }
34
+ end
35
+
36
+ sig { void }
37
+ def reset!
38
+ mutex.synchronize do
39
+ cancel_timer_unlocked
40
+ pending.clear
41
+ end
42
+ end
43
+
44
+ # When paused, refreshes accumulate and run only on an explicit flush! —
45
+ # no background timer fires.
46
+ sig { params(value: T::Boolean).void }
47
+ def paused=(value)
48
+ @paused = T.let(value, T.nilable(T::Boolean))
49
+ end
50
+
51
+ sig { returns(T::Boolean) }
52
+ def paused?
53
+ @paused = T.let(@paused, T.nilable(T::Boolean))
54
+ @paused || false
55
+ end
56
+
57
+ private
58
+
59
+ sig { returns(T::Hash[String, ViewClass]) }
60
+ def pending
61
+ @pending ||= T.let({}, T.nilable(T::Hash[String, ViewClass]))
62
+ end
63
+
64
+ sig { returns(Mutex) }
65
+ def mutex
66
+ @mutex ||= T.let(Mutex.new, T.nilable(Mutex))
67
+ end
68
+
69
+ sig { params(interval: T.any(Integer, Float)).void }
70
+ def schedule_unlocked(interval)
71
+ cancel_timer_unlocked
72
+ return if paused?
73
+
74
+ @timer_thread = T.let(
75
+ Thread.new do
76
+ sleep(interval) unless interval.zero?
77
+ mutex.synchronize { drain_pending_unlocked }
78
+ end,
79
+ T.nilable(Thread)
80
+ )
81
+ end
82
+
83
+ sig { void }
84
+ def cancel_timer_unlocked
85
+ return unless @timer_thread&.alive?
86
+
87
+ @timer_thread.kill
88
+ @timer_thread = nil
89
+ end
90
+
91
+ sig { void }
92
+ def drain_pending_unlocked
93
+ views = pending.values
94
+ pending.clear
95
+
96
+ views.each do |view_class|
97
+ next unless view_class.dirty?
98
+
99
+ view_class.refresh!
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,67 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Infers a view's cache-table columns from its source relation and provisions the table.
7
+ #
8
+ # @api private
9
+ module CacheTableSchema
10
+ extend T::Sig
11
+
12
+ # An inferred cache-table column: name from the relation projection, type
13
+ # one of :integer, :decimal, :boolean, :datetime, :string.
14
+ class ColumnDefinition < T::Struct
15
+ const :name, String
16
+ const :type, Symbol
17
+ end
18
+
19
+ module_function
20
+
21
+ sig { params(view_class: T.class_of(::ActiveRecord::Base), relation: ::ActiveRecord::Relation).void }
22
+ def ensure_table!(view_class, relation)
23
+ return if view_class.table_exists?
24
+
25
+ build_table!(view_class.connection, view_class.table_name, relation)
26
+ view_class.reset_column_information
27
+ end
28
+
29
+ sig { params(view_class: T.class_of(::ActiveRecord::Base), table_name: String, relation: ::ActiveRecord::Relation).void }
30
+ def create_table!(view_class, table_name, relation)
31
+ build_table!(view_class.connection, table_name, relation)
32
+ end
33
+
34
+ # The inferred MV columns (names from the projection, types from a one-row
35
+ # probe). Shared by table creation, migration generation, and drift checks.
36
+ sig { params(connection: Connection, relation: ::ActiveRecord::Relation).returns(T::Array[ColumnDefinition]) }
37
+ def column_definitions(connection, relation)
38
+ result = connection.exec_query(relation.limit(1).to_sql)
39
+ sample = result.rows.first
40
+ result.columns.each_with_index.map do |name, index|
41
+ ColumnDefinition.new(name: name, type: type_for_value(sample&.at(index)))
42
+ end
43
+ end
44
+
45
+ sig { params(connection: Connection, table_name: String, relation: ::ActiveRecord::Relation).void }
46
+ def build_table!(connection, table_name, relation)
47
+ definitions = column_definitions(connection, relation)
48
+ connection.create_table(table_name) do |table|
49
+ definitions.each { |definition| T.unsafe(table).public_send(definition.type, definition.name) }
50
+ end
51
+ end
52
+ private_class_method :build_table!
53
+
54
+ sig { params(value: T.untyped).returns(Symbol) }
55
+ def type_for_value(value)
56
+ case value
57
+ when Integer then :integer
58
+ when Float, BigDecimal then :decimal
59
+ when TrueClass, FalseClass then :boolean
60
+ when Time, Date, DateTime, ::ActiveSupport::TimeWithZone then :datetime
61
+ else :string
62
+ end
63
+ end
64
+ private_class_method :type_for_value
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,60 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # The relation a read is served from when a view is not yet materialized,
7
+ # per its cold_read strategy. Read-through wraps the live source as a derived
8
+ # table aliased to the cache table name, so where/order/limit/count keep
9
+ # working against the same column names.
10
+ class ColdRead
11
+ extend T::Sig
12
+
13
+ sig { params(view_class: ViewClass).void }
14
+ def initialize(view_class)
15
+ @view_class = view_class
16
+ end
17
+
18
+ sig { returns(T.untyped) }
19
+ def scope
20
+ case @view_class.resolved_cold_read_strategy
21
+ when :read_through
22
+ ensure_skeleton!
23
+ unscoped.from(source_derived_table)
24
+ when :serve_stale
25
+ ensure_skeleton!
26
+ unscoped
27
+ when :raise
28
+ Kernel.raise NotMaterializedError, not_materialized_message
29
+ else
30
+ Kernel.raise ArgumentError, "Unknown cold_read strategy: #{@view_class.resolved_cold_read_strategy}"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ sig { returns(T.untyped) }
37
+ def unscoped
38
+ T.unsafe(@view_class).unscoped
39
+ end
40
+
41
+ sig { returns(Arel::Nodes::SqlLiteral) }
42
+ def source_derived_table
43
+ Arel.sql("(#{@view_class.resolved_source.to_sql}) #{@view_class.quoted_table_name}")
44
+ end
45
+
46
+ # Provisions an empty cache table for column metadata — cheap DDL, no data.
47
+ sig { void }
48
+ def ensure_skeleton!
49
+ return if @view_class.table_exists?
50
+
51
+ CacheTableSchema.ensure_table!(@view_class, @view_class.resolved_source)
52
+ end
53
+
54
+ sig { returns(String) }
55
+ def not_materialized_message
56
+ "#{@view_class.name} is not materialized; run #{@view_class.name}.rebuild!(confirm: true)"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Global, app-wide defaults, set via {Materialized.configure}. Individual
7
+ # views can override most of these with the corresponding DSL macro.
8
+ class Configuration
9
+ extend T::Sig
10
+
11
+ # @return [String] name of the metadata table (default "ar_materialized_view_metadata")
12
+ sig { returns(String) }
13
+ attr_accessor :metadata_table_name
14
+
15
+ # @return [String] name of the per-partition freshness table
16
+ sig { returns(String) }
17
+ attr_accessor :partition_table_name
18
+
19
+ # @return [Numeric, ActiveSupport::Duration, nil] default {ViewRefreshPolicyClassMethods::ClassMethods#max_staleness max_staleness}
20
+ sig { returns(T.nilable(DebounceInterval)) }
21
+ attr_accessor :default_max_staleness
22
+
23
+ # @return [Integer, nil] optional per-refresh timeout in seconds
24
+ sig { returns(T.nilable(Integer)) }
25
+ attr_accessor :refresh_timeout
26
+
27
+ # @return [Boolean] whether a full refresh swaps a freshly built table in atomically
28
+ sig { returns(T::Boolean) }
29
+ attr_accessor :atomic_swap_refresh
30
+
31
+ # @return [Symbol] default refresh strategy: +:async+, +:immediate+, or +:manual+
32
+ sig { returns(Symbol) }
33
+ attr_accessor :default_refresh_strategy
34
+
35
+ # @return [Numeric] default debounce window (seconds) for coalescing async refreshes
36
+ sig { returns(DebounceInterval) }
37
+ attr_accessor :default_refresh_debounce
38
+
39
+ # @return [Symbol] background dispatcher: +:async+ (in-process thread) or +:active_job+
40
+ sig { returns(Symbol) }
41
+ attr_accessor :refresh_dispatcher
42
+
43
+ # @return [Symbol] ActiveJob queue name used when +refresh_dispatcher+ is +:active_job+
44
+ sig { returns(Symbol) }
45
+ attr_accessor :refresh_queue_name
46
+
47
+ # Cold-read behavior: :read_through (serve from source), :serve_stale
48
+ # (serve the cache as-is), or :raise.
49
+ sig { returns(Symbol) }
50
+ attr_accessor :default_cold_read_strategy
51
+
52
+ # Cap on distinct partitions tracked in a view's pending maintenance before
53
+ # it collapses to a single full recompute. Bounds the per-write cost of a
54
+ # bulk write that spans many partitions. Defaults to 1000.
55
+ #
56
+ # @return [Integer]
57
+ sig { params(max_tracked_partitions: Integer).void }
58
+ attr_writer :max_tracked_partitions
59
+
60
+ sig { returns(Integer) }
61
+ def max_tracked_partitions
62
+ @max_tracked_partitions ||= T.let(1_000, T.nilable(Integer))
63
+ end
64
+
65
+ sig { void }
66
+ def initialize
67
+ @metadata_table_name = T.let("ar_materialized_view_metadata", String)
68
+ @partition_table_name = T.let("ar_materialized_view_partitions", String)
69
+ @default_max_staleness = T.let(nil, T.nilable(DebounceInterval))
70
+ @refresh_timeout = T.let(nil, T.nilable(Integer))
71
+ @atomic_swap_refresh = T.let(true, T::Boolean)
72
+ @default_refresh_strategy = T.let(:async, Symbol)
73
+ @default_refresh_debounce = T.let(2, DebounceInterval)
74
+ @refresh_dispatcher = T.let(:async, Symbol)
75
+ @refresh_queue_name = T.let(:materialized_views, Symbol)
76
+ @default_cold_read_strategy = T.let(:read_through, Symbol)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,74 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Applies a SummaryDelta to a delta-maintainable view's cache table without
7
+ # re-reading base rows: new partitions inserted, existing ones incremented in
8
+ # place (NULL-safe for SUM), and emptied partitions deleted.
9
+ class DeltaMaintainer
10
+ extend T::Sig
11
+
12
+ sig { params(view_class: ViewClass).void }
13
+ def initialize(view_class)
14
+ @view_class = view_class
15
+ @analysis = T.let(AggregateAnalysis.new(view_class.resolved_source), AggregateAnalysis)
16
+ end
17
+
18
+ sig { params(summary: SummaryDelta).returns(Integer) }
19
+ def apply!(summary)
20
+ summary.buckets.each { |key_tuple, column_deltas| apply_partition(key_tuple, column_deltas) }
21
+ T.unsafe(@view_class).unscoped.count
22
+ end
23
+
24
+ private
25
+
26
+ sig { returns(T::Array[String]) }
27
+ def group_columns
28
+ @view_class.maintenance_key_columns
29
+ end
30
+
31
+ sig { params(key_tuple: SummaryDelta::KeyTuple, column_deltas: T::Hash[String, Numeric]).void }
32
+ def apply_partition(key_tuple, column_deltas)
33
+ existing = partition_scope(key_tuple).first
34
+ if existing.nil?
35
+ insert_partition(key_tuple, column_deltas)
36
+ elsif emptied?(existing, column_deltas)
37
+ existing.destroy!
38
+ else
39
+ add_deltas!(existing, column_deltas)
40
+ end
41
+ end
42
+
43
+ sig { params(key_tuple: SummaryDelta::KeyTuple).returns(::ActiveRecord::Relation) }
44
+ def partition_scope(key_tuple)
45
+ T.unsafe(@view_class).unscoped.where(group_columns.zip(key_tuple).to_h)
46
+ end
47
+
48
+ sig { params(key_tuple: SummaryDelta::KeyTuple, column_deltas: T::Hash[String, Numeric]).void }
49
+ def insert_partition(key_tuple, column_deltas)
50
+ # A new partition's deltas are its absolute values; aggregates with no
51
+ # delta default to 0, not NULL, since the partition has rows.
52
+ defaults = @analysis.aggregate_columns.to_h { |column| [column.name, 0] }
53
+ row = group_columns.zip(key_tuple).to_h.merge(defaults).merge(column_deltas)
54
+ T.unsafe(@view_class).create!(row)
55
+ end
56
+
57
+ # Adds each delta to the current value, treating a NULL SUM as zero.
58
+ sig { params(existing: T.untyped, column_deltas: T::Hash[String, Numeric]).void }
59
+ def add_deltas!(existing, column_deltas)
60
+ new_values = column_deltas.to_h { |column, amount| [column, (existing[column] || 0) + amount] }
61
+ existing.update!(new_values)
62
+ end
63
+
64
+ sig { params(existing: T.untyped, column_deltas: T::Hash[String, Numeric]).returns(T::Boolean) }
65
+ def emptied?(existing, column_deltas)
66
+ column = @analysis.row_count_column
67
+ return false if column.nil?
68
+
69
+ delta = column_deltas[column.name] || 0
70
+ (existing[column.name].to_i + delta) <= 0
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,107 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Maps dependency tables to the views that depend on them and publishes committed writes to them.
7
+ #
8
+ # @api private
9
+ class DependencyRegistry
10
+ class << self
11
+ extend T::Sig
12
+
13
+ sig do
14
+ params(
15
+ view_class: ViewClass,
16
+ tables: T.any(
17
+ Symbol,
18
+ String,
19
+ T.class_of(::ActiveRecord::Base),
20
+ T::Array[T.any(Symbol, String, T.class_of(::ActiveRecord::Base))]
21
+ )
22
+ ).void
23
+ end
24
+ def register(view_class, tables)
25
+ normalized = Array(tables).flat_map { |entry| normalize_dependency(entry) }
26
+ T.unsafe(view_class).instance_variable_set(:@dependency_tables, normalized)
27
+
28
+ normalized.each do |table|
29
+ bucket = T.cast(dependency_index[table], T::Array[ViewClass])
30
+ bucket << view_class unless bucket.include?(view_class)
31
+ subscribe_source_table!(table)
32
+ end
33
+ end
34
+
35
+ sig { params(table: String).returns(T::Array[ViewClass]) }
36
+ def views_for_table(table)
37
+ T.must(dependency_index[normalize_table(table)])
38
+ end
39
+
40
+ sig { params(change: WriteChange).void }
41
+ def publish_write_change!(change)
42
+ affected_views([change.table_name]).each do |view|
43
+ view.record_write_change!(change)
44
+ RefreshScheduler.schedule(view)
45
+ end
46
+ end
47
+
48
+ sig { params(tables: T::Array[String]).void }
49
+ def mark_dirty_for_tables!(tables)
50
+ affected_views(tables).each(&:mark_dependencies_changed!)
51
+ end
52
+
53
+ sig { void }
54
+ def reset!
55
+ @dependency_index = Hash.new { |hash, key| hash[key] = [] }
56
+ ActiveRecord::Materialized::DependencyTrackable.reset!
57
+ end
58
+
59
+ private
60
+
61
+ sig { returns(T::Hash[String, T::Array[ViewClass]]) }
62
+ def dependency_index
63
+ @dependency_index ||= T.let(
64
+ Hash.new { |hash, key| hash[key] = [] },
65
+ T.nilable(T::Hash[String, T::Array[ViewClass]])
66
+ )
67
+ end
68
+
69
+ sig { params(tables: T::Array[String]).returns(T::Array[ViewClass]) }
70
+ def affected_views(tables)
71
+ Array(tables).flat_map do |table|
72
+ next [] if skip_table?(table)
73
+
74
+ views_for_table(table)
75
+ end.uniq
76
+ end
77
+
78
+ sig { params(entry: T.untyped).returns(T::Array[String]) }
79
+ def normalize_dependency(entry)
80
+ if entry.is_a?(Class) && entry < ::ActiveRecord::Base
81
+ TableModelRegistry.register(entry)
82
+ return [entry.table_name]
83
+ end
84
+
85
+ [normalize_table(entry)]
86
+ end
87
+
88
+ sig { params(table: String).void }
89
+ def subscribe_source_table!(table)
90
+ model = TableModelRegistry.resolve(table)
91
+ ActiveRecord::Materialized::DependencyTrackable.subscribe(model) if model
92
+ end
93
+
94
+ sig { params(table: T.any(Symbol, String)).returns(String) }
95
+ def normalize_table(table)
96
+ ::ActiveSupport::Inflector.underscore(table.to_s.delete_prefix(":"))
97
+ end
98
+
99
+ sig { params(table: T.any(Symbol, String)).returns(T::Boolean) }
100
+ def skip_table?(table)
101
+ name = normalize_table(table)
102
+ name.start_with?("mv_") || name == ::ActiveRecord::Materialized.metadata_table_name
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,48 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Installs `after_*_commit` callbacks on `depends_on` models so their writes schedule maintenance.
7
+ #
8
+ # @api private
9
+ module DependencyTrackable
10
+ extend T::Sig
11
+
12
+ TRACKABLE_FLAG = :@ar_materialized_dependency_trackable
13
+
14
+ class << self
15
+ extend T::Sig
16
+
17
+ sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
18
+ def subscribe(model_class)
19
+ return if model_class.instance_variable_get(TRACKABLE_FLAG)
20
+
21
+ install_callbacks!(model_class)
22
+ model_class.instance_variable_set(TRACKABLE_FLAG, true)
23
+ end
24
+
25
+ # Invoked from the model commit callbacks; `record` is the committed instance.
26
+ sig { params(record: ::ActiveRecord::Base, operation: WriteChange::Operation).void }
27
+ def publish(record, operation)
28
+ DependencyRegistry.publish_write_change!(WriteChange.from_record(record, operation))
29
+ end
30
+
31
+ sig { void }
32
+ def reset!
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
39
+ def install_callbacks!(model_class)
40
+ model = T.unsafe(model_class)
41
+ model.after_create_commit { DependencyTrackable.publish(T.unsafe(self), :create) }
42
+ model.after_update_commit { DependencyTrackable.publish(T.unsafe(self), :update) }
43
+ model.after_destroy_commit { DependencyTrackable.publish(T.unsafe(self), :destroy) }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end