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,74 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Tracks every defined view class and provides bulk operations
7
+ # (refresh / rebuild / warm-up / verify) across all of them.
8
+ #
9
+ # @api private
10
+ class Registry
11
+ class << self
12
+ extend T::Sig
13
+
14
+ sig { params(view_class: ViewClass).void }
15
+ def register(view_class)
16
+ views[view_class.view_key] = view_class
17
+ end
18
+
19
+ sig { params(view_class: ViewClass).void }
20
+ def unregister(view_class)
21
+ views.delete(view_class.view_key)
22
+ end
23
+
24
+ sig { params(key: T.any(String, Symbol)).returns(T.nilable(ViewClass)) }
25
+ def find(key)
26
+ views[key.to_s]
27
+ end
28
+
29
+ sig { params(class_name: String).returns(T.nilable(ViewClass)) }
30
+ def for_class_name(class_name)
31
+ all.find { |view| view.name == class_name }
32
+ end
33
+
34
+ sig { returns(T::Array[ViewClass]) }
35
+ def all
36
+ views.values
37
+ end
38
+
39
+ # Incremental pass over every registered view; rebuild_all! for a full one.
40
+ sig { returns(T::Array[RefreshResult]) }
41
+ def refresh_all!
42
+ all.map(&:refresh!)
43
+ end
44
+
45
+ sig { returns(T::Array[RefreshResult]) }
46
+ def refresh_stale!
47
+ all.select(&:stale?).map(&:refresh!)
48
+ end
49
+
50
+ sig { returns(T::Array[RefreshResult]) }
51
+ def rebuild_all!
52
+ all.map { |view| view.rebuild!(confirm: true) }
53
+ end
54
+
55
+ sig { returns(T::Array[T.nilable(RefreshResult)]) }
56
+ def warm_up_all!
57
+ all.map(&:warm_up!)
58
+ end
59
+
60
+ sig { void }
61
+ def reset!
62
+ @views = {}
63
+ end
64
+
65
+ private
66
+
67
+ sig { returns(T::Hash[String, ViewClass]) }
68
+ def views
69
+ @views ||= T.let({}, T.nilable(T::Hash[String, ViewClass]))
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,137 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Materializes a relation into a cache table via `INSERT … SELECT`, with an atomic swap on full refresh.
7
+ #
8
+ # @api private
9
+ class RelationCacheWriter
10
+ extend T::Sig
11
+
12
+ sig { params(view_class: T.class_of(::ActiveRecord::Base)).void }
13
+ def initialize(view_class)
14
+ @view_class = view_class
15
+ end
16
+
17
+ sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
18
+ def bootstrap!(relation)
19
+ CacheTableSchema.ensure_table!(view_class, relation)
20
+ replace_all!(relation)
21
+ end
22
+
23
+ sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
24
+ def replace_all!(relation)
25
+ view_class.transaction do
26
+ view_class.delete_all
27
+ insert_rows!(relation)
28
+ end
29
+ cache_row_count
30
+ end
31
+
32
+ sig do
33
+ params(
34
+ relation: ::ActiveRecord::Relation,
35
+ key_tuples: T::Array[T::Array[String]],
36
+ full_partition: T::Boolean
37
+ ).returns(Integer)
38
+ end
39
+ def replace_partitions!(relation, key_tuples:, full_partition:)
40
+ view_class.transaction do
41
+ if full_partition
42
+ view_class.delete_all
43
+ else
44
+ delete_partitions!(key_tuples)
45
+ end
46
+ insert_rows!(relation)
47
+ end
48
+ cache_row_count
49
+ end
50
+
51
+ sig { params(relation: ::ActiveRecord::Relation).returns(Integer) }
52
+ def atomic_swap!(relation)
53
+ connection = view_class.connection
54
+ temp_table = refresh_temp_table_name
55
+ old_table = refresh_old_table_name
56
+
57
+ populate_temp_table!(temp_table, relation)
58
+ swap_tables!(connection, temp_table, old_table)
59
+
60
+ view_class.reset_column_information
61
+ cache_row_count
62
+ end
63
+
64
+ sig { params(temp_table: String, relation: ::ActiveRecord::Relation).void }
65
+ def populate_temp_table!(temp_table, relation)
66
+ CacheTableSchema.create_table!(T.cast(view_class, ViewClass), temp_table, relation)
67
+ temp_model = temporary_model(temp_table)
68
+ self.class.new(temp_model).replace_all!(relation)
69
+ end
70
+
71
+ sig { params(connection: Connection, temp_table: String, old_table: String).void }
72
+ def swap_tables!(connection, temp_table, old_table)
73
+ connection.transaction do
74
+ connection.rename_table(view_class.table_name, old_table) if view_class.table_exists?
75
+ connection.rename_table(temp_table, view_class.table_name)
76
+ connection.drop_table(old_table, if_exists: true)
77
+ end
78
+ end
79
+
80
+ sig { returns(String) }
81
+ def refresh_temp_table_name
82
+ "#{view_class.table_name}_refresh_#{SecureRandom.hex(4)}"
83
+ end
84
+
85
+ sig { returns(String) }
86
+ def refresh_old_table_name
87
+ "#{view_class.table_name}_old_#{SecureRandom.hex(4)}"
88
+ end
89
+
90
+ private
91
+
92
+ sig { returns(T.class_of(::ActiveRecord::Base)) }
93
+ attr_reader :view_class
94
+
95
+ # Count straight from the cache table, bypassing the View read routing
96
+ # (during a rebuild the view is not warm yet, so a routed count would read
97
+ # through to the source).
98
+ sig { returns(Integer) }
99
+ def cache_row_count
100
+ T.unsafe(view_class).unscoped.count
101
+ end
102
+
103
+ sig { params(key_tuples: T::Array[T::Array[String]]).void }
104
+ def delete_partitions!(key_tuples)
105
+ materialized_view = T.cast(view_class, ViewClass)
106
+ materialized_view.view_definition.partition_scope_on(view_class, key_tuples).delete_all
107
+ end
108
+
109
+ # INSERT ... SELECT entirely in the database; the result set never crosses
110
+ # into Ruby. Cache columns share the relation's projection order, so the
111
+ # SELECT list maps onto them positionally.
112
+ sig { params(relation: ::ActiveRecord::Relation).void }
113
+ def insert_rows!(relation)
114
+ columns = view_class.column_names - ["id"]
115
+ return if columns.empty?
116
+
117
+ T.unsafe(view_class.connection).execute(insert_select_sql(relation, columns))
118
+ end
119
+
120
+ sig { params(relation: ::ActiveRecord::Relation, columns: T::Array[String]).returns(String) }
121
+ def insert_select_sql(relation, columns)
122
+ manager = Arel::InsertManager.new
123
+ manager.into(view_class.arel_table)
124
+ T.unsafe(manager).columns.concat(columns.map { |name| view_class.arel_table[name] })
125
+ manager.select(Arel.sql(relation.to_sql))
126
+ manager.to_sql
127
+ end
128
+
129
+ sig { params(table_name: String).returns(T.class_of(::ActiveRecord::Base)) }
130
+ def temporary_model(table_name)
131
+ klass = Class.new(::ActiveRecord::Base)
132
+ T.unsafe(klass).table_name = table_name
133
+ klass
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,64 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Raises when a provisioned cache table no longer matches the columns its
7
+ # source relation projects (drift); never alters the table or rebuilds data.
8
+ class SchemaVerifier
9
+ extend T::Sig
10
+
11
+ # Raised when a cache table no longer matches the columns its source relation projects.
12
+ class SchemaDriftError < StandardError; end
13
+
14
+ sig { params(view_class: ViewClass).void }
15
+ def initialize(view_class)
16
+ @view_class = view_class
17
+ end
18
+
19
+ # An unprovisioned cache table is absent, not drifted, so this is a no-op.
20
+ sig { void }
21
+ def verify!
22
+ return unless @view_class.table_exists?
23
+
24
+ missing = expected_columns - actual_columns
25
+ extra = actual_columns - expected_columns
26
+ return if missing.empty? && extra.empty?
27
+
28
+ Kernel.raise SchemaDriftError, drift_message(missing, extra)
29
+ end
30
+
31
+ sig { returns(T::Boolean) }
32
+ def drifted?
33
+ verify!
34
+ false
35
+ rescue SchemaDriftError
36
+ true
37
+ end
38
+
39
+ private
40
+
41
+ sig { returns(T::Array[String]) }
42
+ def expected_columns
43
+ CacheTableSchema
44
+ .column_definitions(@view_class.connection, @view_class.resolved_source)
45
+ .map(&:name).sort
46
+ end
47
+
48
+ sig { returns(T::Array[String]) }
49
+ def actual_columns
50
+ @view_class.connection.columns(@view_class.table_name).map(&:name).reject { |name| name == "id" }.sort
51
+ end
52
+
53
+ sig { params(missing: T::Array[String], extra: T::Array[String]).returns(String) }
54
+ def drift_message(missing, extra)
55
+ details = []
56
+ details << "missing columns: #{missing.join(', ')}" if missing.any?
57
+ details << "unexpected columns: #{extra.join(', ')}" if extra.any?
58
+ "#{@view_class.name} cache table #{@view_class.table_name} is out of date " \
59
+ "(#{details.join('; ')}). Generate and run a migration: " \
60
+ "bin/rails generate activerecord_materialized:migration #{@view_class.name}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,76 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Accumulates signed per-partition, per-column numeric changes for a
7
+ # delta-maintainable view (`partition key tuple => mv column => amount`).
8
+ #
9
+ # @api private
10
+ class SummaryDelta
11
+ extend T::Sig
12
+
13
+ KeyTuple = T.type_alias { T::Array[T.untyped] }
14
+ Columns = T.type_alias { T::Hash[String, Numeric] }
15
+ Buckets = T.type_alias { T::Hash[KeyTuple, Columns] }
16
+
17
+ sig { returns(Buckets) }
18
+ attr_reader :buckets
19
+
20
+ sig { params(buckets: Buckets).void }
21
+ def initialize(buckets = {})
22
+ @buckets = buckets
23
+ end
24
+
25
+ sig { params(key_tuple: KeyTuple, column: String, amount: Numeric).void }
26
+ def add(key_tuple, column, amount)
27
+ bucket = (@buckets[key_tuple] ||= {})
28
+ bucket[column] = (bucket[column] || 0) + amount
29
+ end
30
+
31
+ sig { returns(T::Boolean) }
32
+ def empty?
33
+ @buckets.empty?
34
+ end
35
+
36
+ # Number of distinct partitions (buckets) accumulated so far.
37
+ sig { returns(Integer) }
38
+ def tracked_partition_count
39
+ @buckets.size
40
+ end
41
+
42
+ # Drops net-zero columns (no-op changes) and the partitions left empty.
43
+ sig { returns(SummaryDelta) }
44
+ def prune!
45
+ @buckets.each_value { |columns| columns.reject! { |_column, amount| amount.zero? } }
46
+ @buckets.reject! { |_key_tuple, columns| columns.empty? }
47
+ self
48
+ end
49
+
50
+ sig { params(other: SummaryDelta).returns(SummaryDelta) }
51
+ def merge(other)
52
+ merged = SummaryDelta.new(@buckets.transform_values(&:dup))
53
+ other.buckets.each do |key_tuple, columns|
54
+ columns.each { |column, amount| merged.add(key_tuple, column, amount) }
55
+ end
56
+ merged
57
+ end
58
+
59
+ # A list of `"key" => tuple, "columns" => …` entries so array keys survive JSON.
60
+ sig { returns(T::Hash[String, T.untyped]) }
61
+ def serialize
62
+ { "summary" => @buckets.map { |key_tuple, columns| { "key" => key_tuple, "columns" => columns } } }
63
+ end
64
+
65
+ sig { params(payload: T.nilable(T::Hash[String, T.untyped])).returns(T.nilable(SummaryDelta)) }
66
+ def self.deserialize(payload)
67
+ rows = payload && payload["summary"]
68
+ return nil if rows.nil?
69
+
70
+ buckets = T.let({}, Buckets)
71
+ rows.each { |row| buckets[row.fetch("key")] = row.fetch("columns") }
72
+ new(buckets)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # The SummaryDelta a single write contributes. Subtracting the "before"
7
+ # snapshot and adding the "after" uniformly handles inserts, deletes, in-place
8
+ # updates, and group-key changes (a move between partitions).
9
+ class SummaryDeltaBuilder
10
+ extend T::Sig
11
+
12
+ sig { params(change: WriteChange, analysis: AggregateAnalysis, group_columns: T::Array[String]).void }
13
+ def initialize(change, analysis, group_columns)
14
+ @change = change
15
+ @analysis = analysis
16
+ @group_columns = group_columns
17
+ end
18
+
19
+ sig { returns(SummaryDelta) }
20
+ def build
21
+ delta = SummaryDelta.new
22
+ apply!(delta, @change.before, -1) unless @change.before.empty?
23
+ apply!(delta, @change.after, 1) unless @change.after.empty?
24
+ delta.prune!
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(delta: SummaryDelta, snapshot: T::Hash[String, T.untyped], sign: Integer).void }
30
+ def apply!(delta, snapshot, sign)
31
+ key_tuple = key_tuple(snapshot)
32
+ return if key_tuple.nil?
33
+
34
+ @analysis.aggregate_columns.each do |column|
35
+ contribution = contribution_for(column, snapshot)
36
+ delta.add(key_tuple, column.name, sign * contribution) unless contribution.zero?
37
+ end
38
+ end
39
+
40
+ sig { params(column: AggregateAnalysis::Column, snapshot: T::Hash[String, T.untyped]).returns(Numeric) }
41
+ def contribution_for(column, snapshot)
42
+ case column.function
43
+ when :count_star then 1
44
+ when :count then snapshot[T.must(column.attribute)].nil? ? 0 : 1
45
+ when :sum then snapshot.fetch(T.must(column.attribute), 0) || 0
46
+ else 0
47
+ end
48
+ end
49
+
50
+ sig { params(snapshot: T::Hash[String, T.untyped]).returns(T.nilable(SummaryDelta::KeyTuple)) }
51
+ def key_tuple(snapshot)
52
+ return nil unless @group_columns.all? { |column| snapshot.key?(column) }
53
+
54
+ @group_columns.map { |column| snapshot.fetch(column) }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Maps table names to their ActiveRecord models for dependency wiring.
7
+ #
8
+ # @api private
9
+ class TableModelRegistry
10
+ class << self
11
+ extend T::Sig
12
+
13
+ sig { params(model_class: T.class_of(::ActiveRecord::Base)).void }
14
+ def register(model_class)
15
+ return if model_class.abstract_class?
16
+
17
+ explicit[model_class.table_name] = model_class
18
+ end
19
+
20
+ sig { params(table_name: String).returns(T.nilable(T.class_of(::ActiveRecord::Base))) }
21
+ def resolve(table_name)
22
+ explicit[table_name] || find_descendant(table_name)
23
+ end
24
+
25
+ private
26
+
27
+ sig { returns(T::Hash[String, T.class_of(::ActiveRecord::Base)]) }
28
+ def explicit
29
+ @explicit ||= T.let({}, T.nilable(T::Hash[String, T.class_of(::ActiveRecord::Base)]))
30
+ end
31
+
32
+ sig { params(table_name: String).returns(T.nilable(T.class_of(::ActiveRecord::Base))) }
33
+ def find_descendant(table_name)
34
+ ::ActiveRecord::Base.descendants.find do |model_class|
35
+ !model_class.abstract_class? && model_class.table_name == table_name
36
+ end
37
+ rescue StandardError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,79 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # Defines the `materialized:*` rake tasks (refresh_all, refresh_stale, rebuild, verify, warm_up).
7
+ module Tasks
8
+ extend T::Sig
9
+
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
+ def self.define!
23
+ application = T.let(T.unsafe(::Rake.application), T.untyped)
24
+ application.instance_eval do
25
+ T.bind(self, T.untyped)
26
+
27
+ namespace :materialized do
28
+ DEFINITIONS.each do |task_name, description|
29
+ desc description
30
+ task(task_name => :environment) { Tasks.run!(task_name) }
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ sig { params(task_name: Symbol).void }
37
+ 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
45
+ end
46
+
47
+ sig { void }
48
+ def self.run_refresh_all!
49
+ Registry.refresh_all!
50
+ T.unsafe(Rails).logger.debug { "Refreshed #{Registry.all.size} materialized view(s)." }
51
+ end
52
+
53
+ sig { void }
54
+ def self.run_refresh_stale!
55
+ stale = Registry.all.select(&:stale?)
56
+ stale.each(&:refresh!)
57
+ T.unsafe(Rails).logger.debug { "Refreshed #{stale.size} stale materialized view(s)." }
58
+ end
59
+
60
+ sig { void }
61
+ def self.run_rebuild_all!
62
+ Registry.rebuild_all!
63
+ T.unsafe(Rails).logger.debug { "Rebuilt #{Registry.all.size} materialized view(s)." }
64
+ end
65
+
66
+ sig { void }
67
+ def self.run_verify!
68
+ ActiveRecord::Materialized.verify_schema!
69
+ T.unsafe(Rails).logger.debug { "Verified #{Registry.all.size} materialized view schema(s)." }
70
+ end
71
+
72
+ sig { void }
73
+ def self.run_warm_up_all!
74
+ Registry.warm_up_all!
75
+ T.unsafe(Rails).logger.debug { "Warmed up #{Registry.all.size} materialized view(s)." }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,14 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ DebounceInterval = T.type_alias { ::ActiveRecordMaterializedTypes::DebounceInterval }
7
+ StalenessDuration = T.type_alias { ::ActiveRecordMaterializedTypes::StalenessDuration }
8
+ SourceDefinition = T.type_alias { ::ActiveRecordMaterializedTypes::SourceDefinition }
9
+ RefreshMode = T.type_alias { ::ActiveRecordMaterializedTypes::RefreshMode }
10
+ RefreshCallbackName = T.type_alias { ::ActiveRecordMaterializedTypes::RefreshCallbackName }
11
+ Connection = T.type_alias { ::ActiveRecordMaterializedTypes::Connection }
12
+ Timestamp = T.type_alias { ::ActiveRecordMaterializedTypes::Timestamp }
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # The gem version.
7
+ VERSION = "0.1.0"
8
+ end
9
+ end
@@ -0,0 +1,79 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ # The base class for an application-level materialized view. Subclass it,
7
+ # point {ViewConfigurationClassMethods::ClassMethods#materialized_from} at an
8
+ # `ActiveRecord::Relation`, declare the models it
9
+ # {ViewConfigurationClassMethods::ClassMethods#depends_on depends_on}, and then
10
+ # read it like any ActiveRecord model. Reads are served from a cache table the
11
+ # gem maintains incrementally as the underlying data changes; a full
12
+ # materialization happens only via an explicit
13
+ # {ViewQueryAccessClassMethods::ClassMethods#rebuild! rebuild!}, and until then
14
+ # reads transparently fall through to the source query.
15
+ #
16
+ # @example Define a view and use it
17
+ # class RegionRevenue < ActiveRecord::Materialized::View
18
+ # extend ActiveRecord::Materialized::QueryExpressions
19
+ # self.table_name = "mv_region_revenue"
20
+ #
21
+ # materialized_from do
22
+ # sales = Sale.arel_table
23
+ # Sale.group(:region).select(sales[:region], sum_as(sales[:amount], as: :revenue))
24
+ # end
25
+ #
26
+ # depends_on Sale # writes to Sale schedule maintenance
27
+ # refresh_on_change :async # refresh in the background after commit
28
+ # max_staleness 6.hours # optional time-based safety net
29
+ # end
30
+ #
31
+ # RegionRevenue.rebuild!(confirm: true) # materialize once (e.g. at deploy)
32
+ # RegionRevenue.where(region: "west").pick(:revenue) # served from the cache table
33
+ #
34
+ # @see ViewConfigurationClassMethods::ClassMethods +materialized_from+ / +depends_on+ DSL
35
+ # @see ViewRefreshPolicyClassMethods::ClassMethods refresh strategy, staleness, and warm-up
36
+ # @see ViewIncrementalClassMethods::ClassMethods incremental-maintenance configuration
37
+ # @see ViewQueryAccessClassMethods::ClassMethods +rebuild!+, +refresh!+, +materialized?+, …
38
+ class View < ::ActiveRecord::Base
39
+ extend T::Sig
40
+ include RefreshCallbacks
41
+ include ViewConfigurationClassMethods
42
+ include ViewQueryAccessClassMethods
43
+
44
+ self.abstract_class = true
45
+
46
+ class << self
47
+ extend T::Sig
48
+
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))
58
+
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
+ def dependency_tables
67
+ tables = T.let(T.unsafe(self).instance_variable_get(:@dependency_tables), T.nilable(T::Array[String]))
68
+ tables.nil? ? [] : tables
69
+ end
70
+ end
71
+
72
+ sig { returns(T::Boolean) }
73
+ def stale?
74
+ T.bind(self, View)
75
+ self.class.stale?
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module Materialized
6
+ ViewClass = T.type_alias { T.class_of(View) }
7
+ end
8
+ end