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,58 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Hot-path scoped recompute: deletes and re-aggregates only the affected partitions in place.
7
+ #
8
+ # @api private
9
+ class IncrementalMaintainer
10
+ extend T::Sig
11
+
12
+ sig { params(view_class: ViewClass).void }
13
+ def initialize(view_class)
14
+ @view_class = view_class
15
+ end
16
+
17
+ sig { params(_connection: Connection, _table_name: String).returns(Integer) }
18
+ def maintain!(_connection, _table_name)
19
+ delta = maintenance_store.consume_pending_delta!
20
+ relation = resolve_maintenance_relation(delta)
21
+
22
+ row_count = RelationCacheWriter.new(view_class).replace_partitions!(
23
+ relation,
24
+ key_tuples: delta.key_tuples,
25
+ full_partition: delta.full_partition?
26
+ )
27
+
28
+ # On a cold view the maintained partitions are now fresh.
29
+ unless delta.full_partition? || view_class.materialized?
30
+ PartitionState.new(view_class).mark_fresh!(delta.key_tuples)
31
+ end
32
+
33
+ row_count
34
+ end
35
+
36
+ private
37
+
38
+ sig { returns(ViewClass) }
39
+ attr_reader :view_class
40
+
41
+ sig { returns(MaintenanceStore) }
42
+ def maintenance_store
43
+ MaintenanceStore.new(view_class)
44
+ end
45
+
46
+ sig { params(delta: MaintenanceDelta).returns(::ActiveRecord::Relation) }
47
+ def resolve_maintenance_relation(delta)
48
+ if view_class.incremental_source_override?
49
+ view_class.resolved_incremental_source
50
+ elsif delta.full_partition?
51
+ view_class.resolved_source
52
+ else
53
+ view_class.view_definition.partition_scope(delta.key_tuples)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,82 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Pending scoped-recompute maintenance: the affected partition keys, or a full-partition marker.
7
+ #
8
+ # @api private
9
+ class MaintenanceDelta
10
+ extend T::Sig
11
+
12
+ SCOPED = T.let(:scoped, Symbol)
13
+ FULL = T.let(:full_partition, Symbol)
14
+
15
+ sig { returns(Symbol) }
16
+ attr_reader :scope
17
+
18
+ sig { returns(T::Array[T::Array[String]]) }
19
+ attr_reader :key_tuples
20
+
21
+ sig { params(scope: Symbol, key_tuples: T::Array[T::Array[String]]).void }
22
+ def initialize(scope:, key_tuples: [])
23
+ @scope = scope
24
+ @key_tuples = key_tuples
25
+ end
26
+
27
+ sig { params(key_tuples: T::Array[T::Array[String]]).returns(MaintenanceDelta) }
28
+ def self.scoped(key_tuples)
29
+ new(scope: SCOPED, key_tuples: key_tuples)
30
+ end
31
+
32
+ sig { returns(MaintenanceDelta) }
33
+ def self.full_partition
34
+ new(scope: FULL)
35
+ end
36
+
37
+ sig { returns(T::Boolean) }
38
+ def full_partition?
39
+ scope == FULL
40
+ end
41
+
42
+ # How many distinct partitions this pending maintenance tracks. A
43
+ # full-partition recompute tracks none — it is already the collapsed form.
44
+ sig { returns(Integer) }
45
+ def tracked_partition_count
46
+ full_partition? ? 0 : key_tuples.size
47
+ end
48
+
49
+ sig { params(other: MaintenanceDelta).returns(MaintenanceDelta) }
50
+ def merge(other)
51
+ return other if other.full_partition?
52
+ return self if full_partition?
53
+
54
+ combined = (key_tuples + other.key_tuples).uniq
55
+ self.class.scoped(combined)
56
+ end
57
+
58
+ sig { returns(T::Hash[String, T.untyped]) }
59
+ def serialize
60
+ {
61
+ "scope" => scope.to_s,
62
+ "key_tuples" => key_tuples
63
+ }
64
+ end
65
+
66
+ sig { params(payload: T.nilable(T::Hash[String, T.untyped])).returns(T.nilable(MaintenanceDelta)) }
67
+ def self.deserialize(payload)
68
+ return nil if payload.blank?
69
+
70
+ scope_name = payload["scope"]&.to_sym
71
+ return nil if scope_name.nil?
72
+
73
+ if scope_name == FULL
74
+ full_partition
75
+ else
76
+ tuples = T.cast(payload["key_tuples"], T.nilable(T::Array[T::Array[String]])) || []
77
+ scoped(tuples)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,62 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Derives the affected partition keys for a write from its ActiveRecord change payload.
7
+ #
8
+ # @api private
9
+ class MaintenanceDeltaBuilder
10
+ extend T::Sig
11
+
12
+ sig { params(change: WriteChange, key_columns: T::Array[String]).void }
13
+ def initialize(change, key_columns)
14
+ @change = change
15
+ @key_columns = key_columns
16
+ end
17
+
18
+ sig { returns(MaintenanceDelta) }
19
+ def build
20
+ return MaintenanceDelta.full_partition if @key_columns.empty?
21
+
22
+ tuples = extract_tuples
23
+ tuples.empty? ? MaintenanceDelta.full_partition : MaintenanceDelta.scoped(tuples.uniq)
24
+ end
25
+
26
+ private
27
+
28
+ sig { returns(T::Array[T::Array[T.untyped]]) }
29
+ def extract_tuples
30
+ snapshots = case @change.operation.to_sym
31
+ when :create then [@change.after]
32
+ when :destroy then [@change.before]
33
+ when :update then [@change.before, @change.after]
34
+ else []
35
+ end
36
+ snapshots.filter_map { |attributes| key_tuple(attributes) }.uniq
37
+ end
38
+
39
+ sig { params(attributes: T::Hash[String, T.untyped]).returns(T::Boolean) }
40
+ def keys_present?(attributes)
41
+ @key_columns.all? do |column|
42
+ attributes.key?(column) || T.unsafe(attributes).key?(column.to_sym)
43
+ end
44
+ end
45
+
46
+ sig { params(attributes: T::Hash[String, T.untyped]).returns(T.nilable(T::Array[T.untyped])) }
47
+ def key_tuple(attributes)
48
+ return nil unless keys_present?(attributes)
49
+
50
+ @key_columns.map { |column| attribute_value(attributes, column) }
51
+ end
52
+
53
+ sig { params(attributes: T::Hash[String, T.untyped], column: String).returns(T.untyped) }
54
+ def attribute_value(attributes, column)
55
+ # Look up by presence so falsey group-key values map to their partition.
56
+ return attributes[column] if attributes.key?(column)
57
+
58
+ T.unsafe(attributes)[column.to_sym]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,82 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Persists a view's pending maintenance (a delta or a scope) in its metadata row.
7
+ #
8
+ # @api private
9
+ class MaintenanceStore
10
+ extend T::Sig
11
+
12
+ Pending = T.type_alias { T.any(SummaryDelta, MaintenanceDelta) }
13
+
14
+ sig { params(view_class: ViewClass).void }
15
+ def initialize(view_class)
16
+ @view_class = view_class
17
+ end
18
+
19
+ # Accumulates pending maintenance of either kind. A view's mode is fixed
20
+ # within a window, so existing pending is always the same kind. Once the
21
+ # tracked partitions exceed the configured cap, the payload collapses to a
22
+ # single full recompute, so a bulk write spanning many partitions stays
23
+ # O(1) per write instead of re-serializing an ever-growing blob.
24
+ sig { params(delta: Pending).void }
25
+ def merge!(delta)
26
+ metadata.record_maintenance_payload!(T.unsafe(combine(pending, delta)).serialize)
27
+ end
28
+
29
+ sig { returns(T.nilable(Pending)) }
30
+ def pending
31
+ payload = metadata.maintenance_payload
32
+ SummaryDelta.deserialize(payload) || MaintenanceDelta.deserialize(payload)
33
+ end
34
+
35
+ sig { returns(T.nilable(MaintenanceDelta)) }
36
+ def pending_delta
37
+ MaintenanceDelta.deserialize(metadata.maintenance_payload)
38
+ end
39
+
40
+ sig { returns(MaintenanceDelta) }
41
+ def consume_pending_delta!
42
+ delta = pending_delta || MaintenanceDelta.full_partition
43
+ clear!
44
+ delta
45
+ end
46
+
47
+ sig { void }
48
+ def clear!
49
+ metadata.clear_maintenance_payload!
50
+ end
51
+
52
+ private
53
+
54
+ sig { params(current: T.nilable(Pending), delta: Pending).returns(Pending) }
55
+ def combine(current, delta)
56
+ return delta if current.nil?
57
+ return current if recompute_all?(current) # terminal: absorb everything
58
+
59
+ merged = current.instance_of?(delta.class) ? T.unsafe(current).merge(delta) : delta
60
+ oversized?(merged) ? MaintenanceDelta.full_partition : merged
61
+ end
62
+
63
+ sig { params(pending: T.nilable(Pending)).returns(T::Boolean) }
64
+ def recompute_all?(pending)
65
+ pending.is_a?(MaintenanceDelta) && pending.full_partition?
66
+ end
67
+
68
+ sig { params(merged: Pending).returns(T::Boolean) }
69
+ def oversized?(merged)
70
+ merged.tracked_partition_count > ActiveRecord::Materialized.configuration.max_tracked_partitions
71
+ end
72
+
73
+ sig { returns(ViewClass) }
74
+ attr_reader :view_class
75
+
76
+ sig { returns(Metadata) }
77
+ def metadata
78
+ view_class.metadata
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,33 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ class Metadata
7
+ # Reads and writes the serialized pending-maintenance payload on the metadata row.
8
+ #
9
+ # @api private
10
+ module MaintenancePayload
11
+ extend T::Sig
12
+
13
+ sig { params(metadata: Metadata, payload: T::Hash[String, T.untyped]).void }
14
+ def self.record!(metadata, payload)
15
+ metadata.record.update!(maintenance_payload: payload.to_json)
16
+ end
17
+
18
+ sig { params(metadata: Metadata).returns(T.nilable(T::Hash[String, T.untyped])) }
19
+ def self.fetch(metadata)
20
+ raw = T.unsafe(metadata.record).maintenance_payload
21
+ return nil if raw.blank?
22
+
23
+ JSON.parse(raw)
24
+ end
25
+
26
+ sig { params(metadata: Metadata).void }
27
+ def self.clear!(metadata)
28
+ metadata.record.update!(maintenance_payload: nil)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,84 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ class Metadata
7
+ # Lazily provisions and migrates the materialized-view metadata table.
8
+ #
9
+ # @api private
10
+ module Schema
11
+ extend T::Sig
12
+
13
+ module_function
14
+
15
+ sig { params(view_class: ViewClass).void }
16
+ def ensure_table!(view_class)
17
+ connection = view_class.connection
18
+ create_metadata_table!(connection) unless MetadataRecord.table_exists?
19
+ ensure_dirty_column!(connection)
20
+ ensure_maintenance_payload_column!(connection)
21
+ ensure_warm_column!(connection)
22
+ MetadataRecord.reset_column_information
23
+ end
24
+
25
+ sig { params(connection: Connection).void }
26
+ def create_metadata_table!(connection)
27
+ connection.create_table(::ActiveRecord::Materialized.metadata_table_name, force: :cascade) do |t|
28
+ t.string :view_name, null: false
29
+ t.datetime :last_refreshed_at
30
+ t.boolean :refreshing, null: false, default: false
31
+ t.boolean :dirty, null: false, default: true
32
+ t.boolean :warm, null: false, default: false
33
+ t.integer :row_count
34
+ t.integer :refresh_duration_ms
35
+ t.text :last_error
36
+ t.text :maintenance_payload
37
+ t.timestamps
38
+ end
39
+ connection.add_index(::ActiveRecord::Materialized.metadata_table_name, :view_name, unique: true)
40
+ end
41
+
42
+ sig { params(connection: Connection).void }
43
+ def ensure_dirty_column!(connection)
44
+ return unless MetadataRecord.table_exists?
45
+ return if MetadataRecord.column_names.include?("dirty")
46
+
47
+ connection.add_column(
48
+ ::ActiveRecord::Materialized.metadata_table_name,
49
+ :dirty,
50
+ :boolean,
51
+ default: true,
52
+ null: false
53
+ )
54
+ end
55
+
56
+ sig { params(connection: Connection).void }
57
+ def ensure_maintenance_payload_column!(connection)
58
+ return unless MetadataRecord.table_exists?
59
+ return if MetadataRecord.column_names.include?("maintenance_payload")
60
+
61
+ connection.add_column(
62
+ ::ActiveRecord::Materialized.metadata_table_name,
63
+ :maintenance_payload,
64
+ :text
65
+ )
66
+ end
67
+
68
+ sig { params(connection: Connection).void }
69
+ def ensure_warm_column!(connection)
70
+ return unless MetadataRecord.table_exists?
71
+ return if MetadataRecord.column_names.include?("warm")
72
+
73
+ connection.add_column(
74
+ ::ActiveRecord::Materialized.metadata_table_name,
75
+ :warm,
76
+ :boolean,
77
+ default: false,
78
+ null: false
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,31 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ class Metadata
7
+ # Current-time and staleness-threshold helpers for metadata timestamps.
8
+ #
9
+ # @api private
10
+ module Timestamps
11
+ extend T::Sig
12
+
13
+ module_function
14
+
15
+ sig { returns(Timestamp) }
16
+ def current
17
+ ::Time.zone&.now || ::Time.now.utc
18
+ end
19
+
20
+ sig { params(staleness: StalenessDuration).returns(Timestamp) }
21
+ def threshold(staleness)
22
+ if staleness.is_a?(Integer)
23
+ ::ActiveSupport::Duration.seconds(staleness).ago
24
+ else
25
+ staleness.ago
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,138 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "metadata/schema"
5
+ require_relative "metadata/maintenance_payload"
6
+ require_relative "metadata/timestamps"
7
+
8
+ module ActiveRecord
9
+ module Materialized
10
+ # Reads and writes a view's freshness metadata row (dirty, warm, last_refreshed_at, …).
11
+ #
12
+ # @api private
13
+ class Metadata
14
+ extend T::Sig
15
+
16
+ sig { returns(ViewClass) }
17
+ attr_reader :view_class
18
+
19
+ sig { params(view_class: ViewClass).void }
20
+ def initialize(view_class)
21
+ @view_class = view_class
22
+ @schema_ensured = T.let(false, T::Boolean)
23
+ end
24
+
25
+ # Single entry point for the metadata row. Provision the schema once per
26
+ # instance — re-ensuring on every access thrashes the schema cache and
27
+ # makes high-write workloads quadratic.
28
+ sig { returns(MetadataRecord) }
29
+ def record
30
+ ensure_schema!
31
+ MetadataRecord.find_or_initialize_by(view_name: view_class.view_key)
32
+ end
33
+
34
+ sig { void }
35
+ def ensure_schema!
36
+ return if @schema_ensured
37
+
38
+ Schema.ensure_table!(view_class)
39
+ @schema_ensured = true
40
+ end
41
+
42
+ sig { returns(T.nilable(Timestamp)) }
43
+ def last_refreshed_at
44
+ record.last_refreshed_at
45
+ end
46
+
47
+ sig { returns(T::Boolean) }
48
+ def refreshing?
49
+ !!record.refreshing?
50
+ end
51
+
52
+ sig { returns(T.nilable(Integer)) }
53
+ def row_count
54
+ record.row_count
55
+ end
56
+
57
+ sig { returns(T.nilable(Integer)) }
58
+ def refresh_duration_ms
59
+ record.refresh_duration_ms
60
+ end
61
+
62
+ sig { returns(T::Boolean) }
63
+ def dirty?
64
+ !!record.dirty?
65
+ end
66
+
67
+ # Warm once fully materialized via rebuild!; cold views read through.
68
+ sig { returns(T::Boolean) }
69
+ def warm?
70
+ !!record.warm?
71
+ end
72
+
73
+ sig { void }
74
+ def mark_warm!
75
+ record.update!(warm: true)
76
+ end
77
+
78
+ sig { params(max_staleness: T.nilable(StalenessDuration)).returns(T::Boolean) }
79
+ def stale?(max_staleness: view_class.resolved_max_staleness)
80
+ return true if dirty?
81
+ return true if last_refreshed_at.nil?
82
+ return false if max_staleness.nil?
83
+
84
+ refreshed_at = T.must(last_refreshed_at)
85
+ refreshed_at.to_time < Timestamps.threshold(max_staleness).to_time
86
+ end
87
+
88
+ sig { void }
89
+ def mark_dirty!
90
+ record.update!(dirty: true)
91
+ end
92
+
93
+ sig { params(payload: T::Hash[String, T.untyped]).void }
94
+ def record_maintenance_payload!(payload)
95
+ MaintenancePayload.record!(self, payload)
96
+ end
97
+
98
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
99
+ def maintenance_payload
100
+ MaintenancePayload.fetch(self)
101
+ end
102
+
103
+ sig { void }
104
+ def clear_maintenance_payload!
105
+ MaintenancePayload.clear!(self)
106
+ end
107
+
108
+ sig { void }
109
+ def mark_refreshing!
110
+ record.update!(
111
+ refreshing: true,
112
+ last_error: nil
113
+ )
114
+ end
115
+
116
+ sig { params(row_count: Integer, duration_ms: Integer).void }
117
+ def mark_refreshed!(row_count:, duration_ms:)
118
+ record.update!(
119
+ last_refreshed_at: Timestamps.current,
120
+ refreshing: false,
121
+ dirty: false,
122
+ row_count: row_count,
123
+ refresh_duration_ms: duration_ms,
124
+ last_error: nil,
125
+ maintenance_payload: nil
126
+ )
127
+ end
128
+
129
+ sig { params(error: StandardError).void }
130
+ def mark_failed!(error)
131
+ record.update!(
132
+ refreshing: false,
133
+ last_error: error.message
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # ActiveRecord model backing the materialized-view metadata table.
7
+ #
8
+ # @api private
9
+ class MetadataRecord < ::ActiveRecord::Base
10
+ extend T::Sig
11
+
12
+ @table_name_override = T.let(nil, T.nilable(String))
13
+
14
+ self.table_name = ::ActiveRecord::Materialized.metadata_table_name
15
+
16
+ sig { params(name: String).void }
17
+ def self.table_name=(name)
18
+ @table_name_override = name
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def self.table_name
23
+ override = @table_name_override
24
+ override.nil? ? ::ActiveRecord::Materialized.metadata_table_name : override
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # The data a generated migration needs to provision a view's empty cache
7
+ # table: table name, migration class name, target version, and inferred
8
+ # columns/types. The file itself is produced by Rails' generator tooling.
9
+ class MigrationBuilder
10
+ extend T::Sig
11
+
12
+ sig { params(view_class: ViewClass).void }
13
+ def initialize(view_class)
14
+ @view_class = view_class
15
+ end
16
+
17
+ sig { returns(String) }
18
+ def table_name
19
+ @view_class.table_name
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def migration_class_name
24
+ "Create#{table_name.camelize}"
25
+ end
26
+
27
+ sig { returns(T.any(String, Float)) }
28
+ def migration_version
29
+ ::ActiveRecord::Migration.current_version
30
+ end
31
+
32
+ sig { returns(T::Array[CacheTableSchema::ColumnDefinition]) }
33
+ def column_definitions
34
+ CacheTableSchema.column_definitions(@view_class.connection, @view_class.resolved_source)
35
+ end
36
+ end
37
+ end
38
+ end