mat_views 0.1.2
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +121 -0
- data/Rakefile +15 -0
- data/app/assets/stylesheets/mat_views/application.css +15 -0
- data/app/jobs/mat_views/application_job.rb +39 -0
- data/app/jobs/mat_views/create_view_job.rb +188 -0
- data/app/jobs/mat_views/delete_view_job.rb +196 -0
- data/app/jobs/mat_views/refresh_view_job.rb +215 -0
- data/app/models/mat_views/application_record.rb +34 -0
- data/app/models/mat_views/mat_view_definition.rb +98 -0
- data/app/models/mat_views/mat_view_run.rb +89 -0
- data/config/routes.rb +12 -0
- data/lib/generators/mat_views/install/install_generator.rb +86 -0
- data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +29 -0
- data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +32 -0
- data/lib/generators/mat_views/install/templates/mat_views_initializer.rb +23 -0
- data/lib/mat_views/configuration.rb +49 -0
- data/lib/mat_views/engine.rb +34 -0
- data/lib/mat_views/jobs/adapter.rb +78 -0
- data/lib/mat_views/service_response.rb +60 -0
- data/lib/mat_views/services/base_service.rb +308 -0
- data/lib/mat_views/services/concurrent_refresh.rb +177 -0
- data/lib/mat_views/services/create_view.rb +156 -0
- data/lib/mat_views/services/delete_view.rb +160 -0
- data/lib/mat_views/services/regular_refresh.rb +146 -0
- data/lib/mat_views/services/swap_refresh.rb +221 -0
- data/lib/mat_views/version.rb +21 -0
- data/lib/mat_views.rb +57 -0
- data/lib/tasks/helpers.rb +185 -0
- data/lib/tasks/mat_views_tasks.rake +145 -0
- metadata +95 -0
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
4
|
+
#
|
5
|
+
# This source code is licensed under the MIT license found in the
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
7
|
+
|
8
|
+
module MatViews
|
9
|
+
module Services
|
10
|
+
##
|
11
|
+
# Service responsible for creating PostgreSQL materialized views.
|
12
|
+
#
|
13
|
+
# The service validates the view definition, handles existence checks,
|
14
|
+
# executes `CREATE MATERIALIZED VIEW ... WITH DATA`, and, when the
|
15
|
+
# refresh strategy is `:concurrent`, ensures a supporting UNIQUE index.
|
16
|
+
#
|
17
|
+
# Returns a {MatViews::ServiceResponse}.
|
18
|
+
#
|
19
|
+
# @see MatViews::Services::RegularRefresh
|
20
|
+
# @see MatViews::Services::ConcurrentRefresh
|
21
|
+
#
|
22
|
+
# @example Create a new matview (no force)
|
23
|
+
# svc = MatViews::Services::CreateView.new(defn)
|
24
|
+
# response = svc.run
|
25
|
+
# response.status # => :created or :noop
|
26
|
+
#
|
27
|
+
# @example Force recreate an existing matview
|
28
|
+
# svc = MatViews::Services::CreateView.new(defn, force: true)
|
29
|
+
# svc.run
|
30
|
+
#
|
31
|
+
class CreateView < BaseService
|
32
|
+
##
|
33
|
+
# Whether to force recreation (drop+create if exists).
|
34
|
+
#
|
35
|
+
# @return [Boolean]
|
36
|
+
attr_reader :force
|
37
|
+
|
38
|
+
##
|
39
|
+
# @param definition [MatViews::MatViewDefinition]
|
40
|
+
# @param force [Boolean] Whether to drop+recreate an existing matview.
|
41
|
+
def initialize(definition, force: false)
|
42
|
+
super(definition)
|
43
|
+
@force = !!force
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# Execute the create operation.
|
48
|
+
#
|
49
|
+
# - Validates name, SQL, and concurrent-index requirements.
|
50
|
+
# - Handles existing view: noop (default) or drop+recreate (`force: true`).
|
51
|
+
# - Creates the materialized view WITH DATA.
|
52
|
+
# - Creates a UNIQUE index if refresh strategy is concurrent.
|
53
|
+
#
|
54
|
+
# @return [MatViews::ServiceResponse]
|
55
|
+
# - `:created` on success (payload includes `view` and `created_indexes`)
|
56
|
+
# - `:noop` if the view already exists and `force: false`
|
57
|
+
# - `:error` if validation or execution fails
|
58
|
+
#
|
59
|
+
def run
|
60
|
+
prep = prepare!
|
61
|
+
return prep if prep # error response
|
62
|
+
|
63
|
+
# If exists, either noop or drop+recreate
|
64
|
+
existed = handle_existing!
|
65
|
+
return existed if existed.is_a?(MatViews::ServiceResponse)
|
66
|
+
|
67
|
+
# Always create WITH DATA for a fresh view
|
68
|
+
create_with_data
|
69
|
+
|
70
|
+
# For concurrent strategy, ensure the unique index so future
|
71
|
+
# REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
|
72
|
+
index_info = ensure_unique_index_if_needed
|
73
|
+
|
74
|
+
ok(:created, payload: { view: qualified_rel, **index_info })
|
75
|
+
rescue StandardError => e
|
76
|
+
error_response(
|
77
|
+
e,
|
78
|
+
payload: { view: qualified_rel },
|
79
|
+
meta: { sql: sql, force: force }
|
80
|
+
)
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
# ────────────────────────────────────────────────────────────────
|
86
|
+
# internal
|
87
|
+
# ────────────────────────────────────────────────────────────────
|
88
|
+
|
89
|
+
##
|
90
|
+
# Validate name, SQL, and concurrent strategy requirements.
|
91
|
+
#
|
92
|
+
# @api private
|
93
|
+
# @return [MatViews::ServiceResponse, nil] error response or nil if OK
|
94
|
+
#
|
95
|
+
def prepare!
|
96
|
+
return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
|
97
|
+
return err('SQL must start with SELECT') unless valid_sql?
|
98
|
+
return err('refresh_strategy=concurrent requires unique_index_columns (non-empty)') if strategy == 'concurrent' && cols.empty?
|
99
|
+
|
100
|
+
nil
|
101
|
+
end
|
102
|
+
|
103
|
+
##
|
104
|
+
# Handle existing matview: return noop if not forcing, or drop if forcing.
|
105
|
+
#
|
106
|
+
# @api private
|
107
|
+
# @return [MatViews::ServiceResponse, nil]
|
108
|
+
#
|
109
|
+
def handle_existing!
|
110
|
+
return nil unless view_exists?
|
111
|
+
|
112
|
+
return MatViews::ServiceResponse.new(status: :noop) unless force
|
113
|
+
|
114
|
+
drop_view
|
115
|
+
nil
|
116
|
+
end
|
117
|
+
|
118
|
+
##
|
119
|
+
# Execute the CREATE MATERIALIZED VIEW WITH DATA statement.
|
120
|
+
#
|
121
|
+
# @api private
|
122
|
+
# @return [void]
|
123
|
+
#
|
124
|
+
def create_with_data
|
125
|
+
conn.execute(<<~SQL)
|
126
|
+
CREATE MATERIALIZED VIEW #{qualified_rel} AS
|
127
|
+
#{sql}
|
128
|
+
WITH DATA
|
129
|
+
SQL
|
130
|
+
end
|
131
|
+
|
132
|
+
##
|
133
|
+
# Ensure a UNIQUE index if refresh strategy is concurrent.
|
134
|
+
#
|
135
|
+
# Builds an index name like `public_mvname_uniq_col1_col2`.
|
136
|
+
# Creates it concurrently if the PG connection is idle.
|
137
|
+
#
|
138
|
+
# @api private
|
139
|
+
# @return [Hash] `{ created_indexes: [String] }` or empty array if not needed
|
140
|
+
#
|
141
|
+
def ensure_unique_index_if_needed
|
142
|
+
return { created_indexes: [] } unless strategy == 'concurrent'
|
143
|
+
|
144
|
+
# Name like: public_mvname_uniq_col1_col2
|
145
|
+
idx_name = [schema, rel, 'uniq', *cols].join('_')
|
146
|
+
|
147
|
+
concurrently = pg_idle?
|
148
|
+
conn.execute(<<~SQL)
|
149
|
+
CREATE UNIQUE INDEX #{'CONCURRENTLY ' if concurrently}#{quote_table_name(idx_name)}
|
150
|
+
ON #{qualified_rel} (#{cols.map { |c| quote_column_name(c) }.join(', ')})
|
151
|
+
SQL
|
152
|
+
{ created_indexes: [idx_name] }
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
4
|
+
#
|
5
|
+
# This source code is licensed under the MIT license found in the
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
7
|
+
|
8
|
+
module MatViews
|
9
|
+
module Services
|
10
|
+
##
|
11
|
+
# Service that safely drops a PostgreSQL materialized view.
|
12
|
+
#
|
13
|
+
# Options:
|
14
|
+
# - `if_exists:` (Boolean, default: true) → idempotent drop (skip if absent)
|
15
|
+
# - `cascade:` (Boolean, default: false) → use CASCADE instead of RESTRICT
|
16
|
+
#
|
17
|
+
# Returns a {MatViews::ServiceResponse} from {MatViews::Services::BaseService}:
|
18
|
+
# - `ok(:deleted, ...)` when dropped successfully
|
19
|
+
# - `ok(:skipped, ...)` when absent and `if_exists: true`
|
20
|
+
# - `err("...")` or `error_response(...)` on validation or execution error
|
21
|
+
#
|
22
|
+
# @see MatViews::DeleteViewJob
|
23
|
+
# @see MatViews::MatViewRun
|
24
|
+
#
|
25
|
+
# @example Drop a view if it exists
|
26
|
+
# svc = MatViews::Services::DeleteView.new(defn)
|
27
|
+
# svc.run
|
28
|
+
#
|
29
|
+
# @example Force drop with CASCADE
|
30
|
+
# MatViews::Services::DeleteView.new(defn, cascade: true).run
|
31
|
+
#
|
32
|
+
class DeleteView < BaseService
|
33
|
+
##
|
34
|
+
# Whether to cascade the drop (default: false).
|
35
|
+
#
|
36
|
+
# @return [Boolean]
|
37
|
+
attr_reader :cascade
|
38
|
+
|
39
|
+
##
|
40
|
+
# Whether to allow idempotent skipping if view is absent (default: true).
|
41
|
+
#
|
42
|
+
# @return [Boolean]
|
43
|
+
attr_reader :if_exists
|
44
|
+
|
45
|
+
##
|
46
|
+
# @param definition [MatViews::MatViewDefinition]
|
47
|
+
# @param cascade [Boolean] drop with CASCADE instead of RESTRICT
|
48
|
+
# @param if_exists [Boolean] skip if view not present
|
49
|
+
def initialize(definition, cascade: false, if_exists: true)
|
50
|
+
super(definition)
|
51
|
+
@cascade = cascade ? true : false
|
52
|
+
@if_exists = if_exists ? true : false
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
# Run the drop operation.
|
57
|
+
#
|
58
|
+
# Steps:
|
59
|
+
# - Validate name format and (optionally) existence.
|
60
|
+
# - Return `:skipped` if absent and `if_exists` true.
|
61
|
+
# - Execute DROP MATERIALIZED VIEW.
|
62
|
+
#
|
63
|
+
# @return [MatViews::ServiceResponse]
|
64
|
+
#
|
65
|
+
def run
|
66
|
+
prep = prepare!
|
67
|
+
return prep if prep
|
68
|
+
|
69
|
+
res = skip_early_if_absent
|
70
|
+
return res if res
|
71
|
+
|
72
|
+
perform_drop
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
# ────────────────────────────────────────────────────────────────
|
78
|
+
# internal
|
79
|
+
# ────────────────────────────────────────────────────────────────
|
80
|
+
|
81
|
+
##
|
82
|
+
# Execute the DROP MATERIALIZED VIEW statement.
|
83
|
+
#
|
84
|
+
# @api private
|
85
|
+
# @return [MatViews::ServiceResponse]
|
86
|
+
#
|
87
|
+
def perform_drop
|
88
|
+
conn.execute(sql)
|
89
|
+
|
90
|
+
ok(:deleted,
|
91
|
+
payload: { view: "#{schema}.#{rel}" },
|
92
|
+
meta: { sql: sql, cascade: cascade, if_exists: if_exists })
|
93
|
+
rescue ActiveRecord::StatementInvalid => e
|
94
|
+
msg = "#{e.message} — dependencies exist. Use cascade: true to force drop."
|
95
|
+
error_response(
|
96
|
+
e.class.new(msg),
|
97
|
+
meta: { sql: sql, cascade: cascade, if_exists: if_exists },
|
98
|
+
payload: { view: "#{schema}.#{rel}" }
|
99
|
+
)
|
100
|
+
rescue StandardError => e
|
101
|
+
error_response(
|
102
|
+
e,
|
103
|
+
meta: { sql: sql, cascade: cascade, if_exists: if_exists },
|
104
|
+
payload: { view: "#{schema}.#{rel}" }
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
##
|
109
|
+
# Skip early if view is absent and `if_exists` is true.
|
110
|
+
#
|
111
|
+
# @api private
|
112
|
+
# @return [MatViews::ServiceResponse, nil]
|
113
|
+
#
|
114
|
+
def skip_early_if_absent
|
115
|
+
return nil unless if_exists
|
116
|
+
return nil if view_exists?
|
117
|
+
|
118
|
+
ok(:skipped,
|
119
|
+
payload: { view: "#{schema}.#{rel}" },
|
120
|
+
meta: { sql: nil, cascade: cascade, if_exists: if_exists })
|
121
|
+
end
|
122
|
+
|
123
|
+
##
|
124
|
+
# Build the SQL DROP statement.
|
125
|
+
#
|
126
|
+
# @api private
|
127
|
+
# @return [String]
|
128
|
+
#
|
129
|
+
def sql
|
130
|
+
@sql ||= build_sql
|
131
|
+
end
|
132
|
+
|
133
|
+
##
|
134
|
+
# Validate name and existence depending on options.
|
135
|
+
#
|
136
|
+
# @api private
|
137
|
+
# @return [MatViews::ServiceResponse, nil]
|
138
|
+
#
|
139
|
+
def prepare!
|
140
|
+
return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
|
141
|
+
return nil if if_exists # skip hard existence check
|
142
|
+
|
143
|
+
return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
|
144
|
+
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
|
148
|
+
##
|
149
|
+
# Construct DROP SQL with cascade/restrict options.
|
150
|
+
#
|
151
|
+
# @api private
|
152
|
+
# @return [String]
|
153
|
+
#
|
154
|
+
def build_sql
|
155
|
+
drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
|
156
|
+
%(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
4
|
+
#
|
5
|
+
# This source code is licensed under the MIT license found in the
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
7
|
+
|
8
|
+
module MatViews
|
9
|
+
module Services
|
10
|
+
##
|
11
|
+
# Service that executes a standard (locking) `REFRESH MATERIALIZED VIEW`.
|
12
|
+
#
|
13
|
+
# This is the safest option for simple or low-frequency updates where
|
14
|
+
# blocking reads during refresh is acceptable.
|
15
|
+
#
|
16
|
+
# Supports optional row counting strategies:
|
17
|
+
# - `:estimated` → uses `pg_class.reltuples` (fast, approximate)
|
18
|
+
# - `:exact` → runs `COUNT(*)` (accurate, but potentially slow)
|
19
|
+
# - `nil` → no row count included in payload
|
20
|
+
#
|
21
|
+
# @return [MatViews::ServiceResponse]
|
22
|
+
#
|
23
|
+
# @example
|
24
|
+
# svc = MatViews::Services::RegularRefresh.new(defn)
|
25
|
+
# svc.run
|
26
|
+
#
|
27
|
+
class RegularRefresh < BaseService
|
28
|
+
##
|
29
|
+
# The row count strategy requested.
|
30
|
+
# One of `:estimated`, `:exact`, `nil`, or unrecognized symbol.
|
31
|
+
#
|
32
|
+
# @return [Symbol, nil]
|
33
|
+
attr_reader :row_count_strategy
|
34
|
+
|
35
|
+
##
|
36
|
+
# @param definition [MatViews::MatViewDefinition]
|
37
|
+
# @param row_count_strategy [Symbol, nil] row counting mode
|
38
|
+
def initialize(definition, row_count_strategy: :estimated)
|
39
|
+
super(definition)
|
40
|
+
@row_count_strategy = row_count_strategy
|
41
|
+
end
|
42
|
+
|
43
|
+
##
|
44
|
+
# Perform the refresh.
|
45
|
+
#
|
46
|
+
# Steps:
|
47
|
+
# - Validate name & existence.
|
48
|
+
# - Run `REFRESH MATERIALIZED VIEW`.
|
49
|
+
# - Optionally compute row count.
|
50
|
+
#
|
51
|
+
# @return [MatViews::ServiceResponse]
|
52
|
+
#
|
53
|
+
def run
|
54
|
+
prep = prepare!
|
55
|
+
return prep if prep
|
56
|
+
|
57
|
+
sql = "REFRESH MATERIALIZED VIEW #{qualified_rel}"
|
58
|
+
|
59
|
+
conn.execute(sql)
|
60
|
+
|
61
|
+
payload = { view: "#{schema}.#{rel}" }
|
62
|
+
payload[:row_count] = fetch_rows_count if row_count_strategy.present?
|
63
|
+
|
64
|
+
ok(:updated,
|
65
|
+
payload: payload,
|
66
|
+
meta: { sql: sql, row_count_strategy: row_count_strategy })
|
67
|
+
rescue StandardError => e
|
68
|
+
error_response(
|
69
|
+
e,
|
70
|
+
meta: {
|
71
|
+
sql: sql,
|
72
|
+
backtrace: Array(e.backtrace),
|
73
|
+
row_count_strategy: row_count_strategy
|
74
|
+
},
|
75
|
+
payload: { view: "#{schema}.#{rel}" }
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# ────────────────────────────────────────────────────────────────
|
82
|
+
# internal
|
83
|
+
# ────────────────────────────────────────────────────────────────
|
84
|
+
|
85
|
+
##
|
86
|
+
# Validate name and existence of the materialized view.
|
87
|
+
#
|
88
|
+
# @api private
|
89
|
+
# @return [MatViews::ServiceResponse, nil]
|
90
|
+
#
|
91
|
+
def prepare!
|
92
|
+
return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
|
93
|
+
return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
|
94
|
+
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
|
98
|
+
# ────────────────────────────────────────────────────────────────
|
99
|
+
# rows counting
|
100
|
+
# ────────────────────────────────────────────────────────────────
|
101
|
+
|
102
|
+
##
|
103
|
+
# Pick the appropriate row count method.
|
104
|
+
#
|
105
|
+
# @api private
|
106
|
+
# @return [Integer, nil]
|
107
|
+
#
|
108
|
+
def fetch_rows_count
|
109
|
+
case row_count_strategy
|
110
|
+
when :estimated then estimated_rows_count
|
111
|
+
when :exact then exact_rows_count
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
##
|
116
|
+
# Fast/approx via `pg_class.reltuples`.
|
117
|
+
# Updated by `ANALYZE` and autovacuum.
|
118
|
+
#
|
119
|
+
# @api private
|
120
|
+
# @return [Integer]
|
121
|
+
#
|
122
|
+
def estimated_rows_count
|
123
|
+
conn.select_value(<<~SQL).to_i
|
124
|
+
SELECT COALESCE(c.reltuples::bigint, 0)
|
125
|
+
FROM pg_class c
|
126
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
127
|
+
WHERE c.relkind IN ('m','r','p')
|
128
|
+
AND n.nspname = #{conn.quote(schema)}
|
129
|
+
AND c.relname = #{conn.quote(rel)}
|
130
|
+
LIMIT 1
|
131
|
+
SQL
|
132
|
+
end
|
133
|
+
|
134
|
+
##
|
135
|
+
# Accurate count via `COUNT(*)`.
|
136
|
+
# Potentially slow on large materialized views.
|
137
|
+
#
|
138
|
+
# @api private
|
139
|
+
# @return [Integer]
|
140
|
+
#
|
141
|
+
def exact_rows_count
|
142
|
+
conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
4
|
+
#
|
5
|
+
# This source code is licensed under the MIT license found in the
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
7
|
+
|
8
|
+
require 'securerandom'
|
9
|
+
|
10
|
+
module MatViews
|
11
|
+
module Services
|
12
|
+
##
|
13
|
+
# Service that performs a **swap-style refresh** of a materialized view.
|
14
|
+
#
|
15
|
+
# Instead of locking the existing view, this strategy builds a new
|
16
|
+
# temporary materialized view and atomically swaps it in. This approach
|
17
|
+
# minimizes downtime and allows for safer rebuilds of large views.
|
18
|
+
#
|
19
|
+
# Steps:
|
20
|
+
# 1. Create a temporary MV from the provided SQL.
|
21
|
+
# 2. In a transaction: rename original → old, tmp → original, drop old.
|
22
|
+
# 3. Recreate declared unique indexes (if any).
|
23
|
+
#
|
24
|
+
# Supports optional row count strategies:
|
25
|
+
# - `:estimated` → approximate, using `pg_class.reltuples`
|
26
|
+
# - `:exact` → accurate, using `COUNT(*)`
|
27
|
+
# - `nil` → skip row count
|
28
|
+
#
|
29
|
+
# @return [MatViews::ServiceResponse]
|
30
|
+
#
|
31
|
+
# @example
|
32
|
+
# svc = MatViews::Services::SwapRefresh.new(defn, row_count_strategy: :exact)
|
33
|
+
# svc.run
|
34
|
+
#
|
35
|
+
class SwapRefresh < BaseService
|
36
|
+
##
|
37
|
+
# Row count strategy (`:estimated`, `:exact`, `nil`).
|
38
|
+
#
|
39
|
+
# @return [Symbol, nil]
|
40
|
+
attr_reader :row_count_strategy
|
41
|
+
|
42
|
+
##
|
43
|
+
# @param definition [MatViews::MatViewDefinition]
|
44
|
+
# @param row_count_strategy [Symbol, nil]
|
45
|
+
def initialize(definition, row_count_strategy: :estimated)
|
46
|
+
super(definition)
|
47
|
+
@row_count_strategy = row_count_strategy
|
48
|
+
end
|
49
|
+
|
50
|
+
##
|
51
|
+
# Execute the swap refresh.
|
52
|
+
#
|
53
|
+
# @return [MatViews::ServiceResponse]
|
54
|
+
def run
|
55
|
+
prep = prepare!
|
56
|
+
return prep if prep
|
57
|
+
|
58
|
+
create_sql = %(CREATE MATERIALIZED VIEW #{q_tmp} AS #{definition.sql} WITH DATA)
|
59
|
+
steps = [create_sql]
|
60
|
+
conn.execute(create_sql)
|
61
|
+
|
62
|
+
steps.concat(swap_index)
|
63
|
+
|
64
|
+
payload = { view: "#{schema}.#{rel}" }
|
65
|
+
payload[:row_count] = fetch_rows_count if row_count_strategy.present?
|
66
|
+
|
67
|
+
ok(:updated, payload: payload, meta: { steps: steps, row_count_strategy: row_count_strategy, swap: true })
|
68
|
+
rescue StandardError => e
|
69
|
+
error_response(e,
|
70
|
+
meta: {
|
71
|
+
steps: steps,
|
72
|
+
backtrace: Array(e.backtrace),
|
73
|
+
row_count_strategy: row_count_strategy,
|
74
|
+
swap: true
|
75
|
+
},
|
76
|
+
payload: { view: "#{schema}.#{rel}" })
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# ────────────────────────────────────────────────────────────────
|
82
|
+
# internal
|
83
|
+
# ────────────────────────────────────────────────────────────────
|
84
|
+
|
85
|
+
##
|
86
|
+
# Ensure name validity and existence of original view.
|
87
|
+
#
|
88
|
+
# @api private
|
89
|
+
# @return [MatViews::ServiceResponse, nil]
|
90
|
+
def prepare!
|
91
|
+
return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
|
92
|
+
return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
|
93
|
+
|
94
|
+
nil
|
95
|
+
end
|
96
|
+
|
97
|
+
##
|
98
|
+
# Perform rename/drop/index recreation in a transaction.
|
99
|
+
#
|
100
|
+
# @api private
|
101
|
+
# @return [Array<String>] SQL steps executed
|
102
|
+
def swap_index
|
103
|
+
steps = []
|
104
|
+
conn.transaction do
|
105
|
+
rename_orig_sql = %(ALTER MATERIALIZED VIEW #{qualified_rel} RENAME TO #{conn.quote_column_name(old_rel)})
|
106
|
+
steps << rename_orig_sql
|
107
|
+
conn.execute(rename_orig_sql)
|
108
|
+
|
109
|
+
rename_tmp_sql = %(ALTER MATERIALIZED VIEW #{q_tmp} RENAME TO #{conn.quote_column_name(rel)})
|
110
|
+
steps << rename_tmp_sql
|
111
|
+
conn.execute(rename_tmp_sql)
|
112
|
+
|
113
|
+
drop_old_sql = %(DROP MATERIALIZED VIEW #{q_old})
|
114
|
+
steps << drop_old_sql
|
115
|
+
conn.execute(drop_old_sql)
|
116
|
+
|
117
|
+
recreate_declared_unique_indexes!(schema:, rel:, steps:)
|
118
|
+
end
|
119
|
+
steps
|
120
|
+
end
|
121
|
+
|
122
|
+
##
|
123
|
+
# Quote the temporary materialized view name.
|
124
|
+
#
|
125
|
+
# @api private
|
126
|
+
# @return [String] quoted temporary view name
|
127
|
+
def q_tmp
|
128
|
+
@q_tmp ||= conn.quote_table_name("#{schema}.#{tmp_rel}")
|
129
|
+
end
|
130
|
+
|
131
|
+
##
|
132
|
+
# Quote the original materialized view name.
|
133
|
+
#
|
134
|
+
# @api private
|
135
|
+
# @return [String] quoted original view name
|
136
|
+
def q_old
|
137
|
+
@q_old ||= conn.quote_table_name("#{schema}.#{old_rel}")
|
138
|
+
end
|
139
|
+
|
140
|
+
##
|
141
|
+
# Fully-qualified, safely-quoted temporary relation name.
|
142
|
+
#
|
143
|
+
# @api private
|
144
|
+
# @return [String]
|
145
|
+
def tmp_rel
|
146
|
+
@tmp_rel ||= "#{rel}__tmp_#{SecureRandom.hex(4)}"
|
147
|
+
end
|
148
|
+
|
149
|
+
##
|
150
|
+
# Fully-qualified, safely-quoted old relation name.
|
151
|
+
#
|
152
|
+
# @api private
|
153
|
+
# @return [String]
|
154
|
+
def old_rel
|
155
|
+
@old_rel ||= "#{rel}__old_#{SecureRandom.hex(4)}"
|
156
|
+
end
|
157
|
+
|
158
|
+
##
|
159
|
+
# Recreate declared unique indexes on the swapped-in view.
|
160
|
+
#
|
161
|
+
# @api private
|
162
|
+
# @param schema [String]
|
163
|
+
# @param rel [String]
|
164
|
+
# @param steps [Array<String>] collected SQL
|
165
|
+
def recreate_declared_unique_indexes!(schema:, rel:, steps:)
|
166
|
+
cols = Array(definition.unique_index_columns).map(&:to_s).reject(&:empty?)
|
167
|
+
return if cols.empty?
|
168
|
+
|
169
|
+
quoted_cols = cols.map { |c| conn.quote_column_name(c) }.join(', ')
|
170
|
+
idx_name = conn.quote_column_name("#{rel}_uniq_#{cols.join('_')}")
|
171
|
+
q_rel = conn.quote_table_name("#{schema}.#{rel}")
|
172
|
+
|
173
|
+
sql = %(CREATE UNIQUE INDEX #{idx_name} ON #{q_rel} (#{quoted_cols}))
|
174
|
+
steps << sql
|
175
|
+
conn.execute(sql)
|
176
|
+
end
|
177
|
+
|
178
|
+
# ────────────────────────────────────────────────────────────────
|
179
|
+
# rows counting
|
180
|
+
# ────────────────────────────────────────────────────────────────
|
181
|
+
|
182
|
+
##
|
183
|
+
# Fetch the row count based on the configured strategy.
|
184
|
+
#
|
185
|
+
# @api private
|
186
|
+
# @return [Integer, nil]
|
187
|
+
def fetch_rows_count
|
188
|
+
case row_count_strategy
|
189
|
+
when :estimated then estimated_rows_count
|
190
|
+
when :exact then exact_rows_count
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
##
|
195
|
+
# Approximate row count via `pg_class.reltuples`.
|
196
|
+
#
|
197
|
+
# @api private
|
198
|
+
# @return [Integer]
|
199
|
+
def estimated_rows_count
|
200
|
+
conn.select_value(<<~SQL).to_i
|
201
|
+
SELECT COALESCE(c.reltuples::bigint, 0)
|
202
|
+
FROM pg_class c
|
203
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
204
|
+
WHERE c.relkind IN ('m','r','p')
|
205
|
+
AND n.nspname = #{conn.quote(schema)}
|
206
|
+
AND c.relname = #{conn.quote(rel)}
|
207
|
+
LIMIT 1
|
208
|
+
SQL
|
209
|
+
end
|
210
|
+
|
211
|
+
##
|
212
|
+
# Accurate row count via `COUNT(*)`.
|
213
|
+
#
|
214
|
+
# @api private
|
215
|
+
# @return [Integer]
|
216
|
+
def exact_rows_count
|
217
|
+
conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
4
|
+
#
|
5
|
+
# This source code is licensed under the MIT license found in the
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
7
|
+
|
8
|
+
module MatViews
|
9
|
+
##
|
10
|
+
# Defines the version of the MatViews gem.
|
11
|
+
#
|
12
|
+
# This constant is used to track and publish gem releases.
|
13
|
+
# It follows [Semantic Versioning](https://semver.org/):
|
14
|
+
#
|
15
|
+
# - MAJOR: Incompatible API changes
|
16
|
+
# - MINOR: Backwards-compatible functionality
|
17
|
+
# - PATCH: Backwards-compatible bug fixes
|
18
|
+
#
|
19
|
+
# @return [String] the current gem version
|
20
|
+
VERSION = '0.1.2'
|
21
|
+
end
|