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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +138 -323
- data/lib/activerecord/materialized/aggregate_analysis.rb +10 -31
- data/lib/activerecord/materialized/async_refresher.rb +18 -27
- data/lib/activerecord/materialized/cache_table_schema.rb +53 -34
- data/lib/activerecord/materialized/change_source.rb +25 -0
- data/lib/activerecord/materialized/cold_read.rb +1 -10
- data/lib/activerecord/materialized/column_type_inference.rb +167 -0
- data/lib/activerecord/materialized/configurable_table_name.rb +45 -0
- data/lib/activerecord/materialized/configuration.rb +104 -29
- data/lib/activerecord/materialized/connection_routing.rb +29 -0
- data/lib/activerecord/materialized/data_verification_result.rb +41 -0
- data/lib/activerecord/materialized/data_verifier.rb +162 -0
- data/lib/activerecord/materialized/debezium_envelope.rb +105 -0
- data/lib/activerecord/materialized/delta_maintainer.rb +4 -15
- data/lib/activerecord/materialized/dependency_registry.rb +43 -35
- data/lib/activerecord/materialized/dependency_trackable.rb +7 -14
- data/lib/activerecord/materialized/incremental_maintainer.rb +45 -28
- data/lib/activerecord/materialized/incremental_refresh.rb +63 -0
- data/lib/activerecord/materialized/instrumentation.rb +102 -0
- data/lib/activerecord/materialized/maintenance_delta.rb +4 -19
- data/lib/activerecord/materialized/maintenance_delta_builder.rb +46 -14
- data/lib/activerecord/materialized/maintenance_store.rb +58 -21
- data/lib/activerecord/materialized/metadata/maintenance_payload.rb +1 -7
- data/lib/activerecord/materialized/metadata/reconciliation.rb +25 -0
- data/lib/activerecord/materialized/metadata/schema.rb +19 -41
- data/lib/activerecord/materialized/metadata/timestamps.rb +0 -5
- data/lib/activerecord/materialized/metadata.rb +41 -25
- data/lib/activerecord/materialized/metadata_record.rb +2 -16
- data/lib/activerecord/materialized/migration_builder.rb +8 -8
- data/lib/activerecord/materialized/module_api.rb +219 -22
- data/lib/activerecord/materialized/partition_filter.rb +45 -0
- data/lib/activerecord/materialized/partition_keyed_store.rb +72 -0
- data/lib/activerecord/materialized/partition_record.rb +2 -16
- data/lib/activerecord/materialized/partition_snapshot.rb +79 -0
- data/lib/activerecord/materialized/partition_state.rb +47 -46
- data/lib/activerecord/materialized/query_expressions.rb +0 -12
- data/lib/activerecord/materialized/railtie.rb +15 -3
- data/lib/activerecord/materialized/read_router.rb +51 -0
- data/lib/activerecord/materialized/reconcile_job.rb +22 -0
- data/lib/activerecord/materialized/reconcile_result.rb +36 -0
- data/lib/activerecord/materialized/reconciler.rb +79 -0
- data/lib/activerecord/materialized/refresh_callbacks.rb +4 -18
- data/lib/activerecord/materialized/refresh_job.rb +0 -4
- data/lib/activerecord/materialized/refresh_result.rb +2 -10
- data/lib/activerecord/materialized/refresh_scheduler.rb +5 -7
- data/lib/activerecord/materialized/refresher.rb +35 -50
- data/lib/activerecord/materialized/registry.rb +26 -16
- data/lib/activerecord/materialized/relation_cache_writer.rb +12 -46
- data/lib/activerecord/materialized/schema_verifier.rb +0 -9
- data/lib/activerecord/materialized/source_watermark.rb +93 -0
- data/lib/activerecord/materialized/source_watermark_record.rb +16 -0
- data/lib/activerecord/materialized/summary_delta.rb +1 -17
- data/lib/activerecord/materialized/summary_delta_builder.rb +2 -10
- data/lib/activerecord/materialized/table_model_registry.rb +1 -8
- data/lib/activerecord/materialized/table_swap.rb +106 -0
- data/lib/activerecord/materialized/tasks.rb +46 -38
- data/lib/activerecord/materialized/version.rb +1 -2
- data/lib/activerecord/materialized/view.rb +11 -22
- data/lib/activerecord/materialized/view_configuration_class_methods.rb +23 -35
- data/lib/activerecord/materialized/view_definition.rb +76 -65
- data/lib/activerecord/materialized/view_incremental_class_methods.rb +57 -60
- data/lib/activerecord/materialized/view_loader.rb +49 -0
- data/lib/activerecord/materialized/view_query_access_class_methods.rb +100 -57
- data/lib/activerecord/materialized/view_refresh_policy_class_methods.rb +70 -37
- data/lib/activerecord/materialized/write_change.rb +75 -26
- data/lib/activerecord/materialized/write_maintenance.rb +73 -0
- data/lib/activerecord/materialized/write_outbox.rb +345 -0
- data/lib/activerecord/materialized/write_outbox_record.rb +17 -0
- data/lib/activerecord/materialized.rb +27 -6
- data/lib/generators/activerecord_materialized/install/templates/create_ar_materialized_view_metadata.rb.erb +14 -0
- data/lib/generators/activerecord_materialized/install_generator.rb +1 -6
- data/lib/generators/activerecord_materialized/migration_generator.rb +3 -9
- data/lib/generators/activerecord_materialized/outbox_generator.rb +36 -0
- data/lib/generators/activerecord_materialized/templates/materialized_view_migration.rb.erb +5 -1
- data/lib/generators/activerecord_materialized/templates/write_outbox_migration.rb.erb +21 -0
- data/lib/generators/activerecord_materialized/view_generator.rb +0 -4
- metadata +27 -18
- data/lib/activerecord/materialized/type_reexports.rb +0 -14
- data/lib/activerecord/materialized/view_class.rb +0 -8
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
31
|
+
# Each task name maps by convention to its `run_<task_name>!` module method.
|
|
37
32
|
def self.run!(task_name)
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
42
|
+
stale = Registry.stale_views
|
|
56
43
|
stale.each(&:refresh!)
|
|
57
|
-
|
|
44
|
+
Rails.logger.debug { "Refreshed #{stale.size} stale materialized view(s)." }
|
|
58
45
|
end
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
def self.run_rebuild_all!
|
|
47
|
+
def self.run_rebuild!
|
|
62
48
|
Registry.rebuild_all!
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
def self.run_warm_up_all!
|
|
81
|
+
def self.run_warm_up!
|
|
74
82
|
Registry.warm_up_all!
|
|
75
|
-
|
|
83
|
+
Rails.logger.debug { "Warmed up #{Registry.all.size} materialized view(s)." }
|
|
76
84
|
end
|
|
77
85
|
end
|
|
78
86
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
36
|
+
return view_class.name.underscore if view_class.name.present?
|
|
43
37
|
|
|
44
|
-
table =
|
|
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
|
-
|
|
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 =
|
|
47
|
+
@source_definition = block
|
|
51
48
|
Registry.register(view_class) unless view_class.abstract_class?
|
|
52
49
|
end
|
|
53
50
|
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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 =
|
|
55
|
-
attributes = group_key_columns.map { |column|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
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
|