smriti 0.5.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/Rakefile +15 -0
- data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
- data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
- data/app/assets/images/smriti/apple-touch-icon.png +0 -0
- data/app/assets/images/smriti/favicon-16x16.png +0 -0
- data/app/assets/images/smriti/favicon-32x32.png +0 -0
- data/app/assets/images/smriti/favicon-48x48.png +0 -0
- data/app/assets/images/smriti/favicon.ico +0 -0
- data/app/assets/images/smriti/favicon.svg +18 -0
- data/app/assets/images/smriti/logo.svg +18 -0
- data/app/assets/images/smriti/mask-icon.svg +5 -0
- data/app/assets/stylesheets/smriti/application.css +1040 -0
- data/app/controllers/smriti/admin/application_controller.rb +135 -0
- data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
- data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
- data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
- data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
- data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
- data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
- data/app/helpers/smriti/admin/ui_helper.rb +539 -0
- data/app/javascript/smriti/application.js +8 -0
- data/app/javascript/smriti/controllers/application.js +10 -0
- data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
- data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
- data/app/javascript/smriti/controllers/details_controller.js +200 -0
- data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
- data/app/javascript/smriti/controllers/flash_controller.js +112 -0
- data/app/javascript/smriti/controllers/index.js +10 -0
- data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
- data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
- data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
- data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
- data/app/jobs/smriti/application_job.rb +144 -0
- data/app/jobs/smriti/create_view_job.rb +87 -0
- data/app/jobs/smriti/delete_view_job.rb +89 -0
- data/app/jobs/smriti/refresh_view_job.rb +94 -0
- data/app/models/concerns/smriti_i18n.rb +139 -0
- data/app/models/concerns/smriti_paginate.rb +70 -0
- data/app/models/concerns/smriti_query_helper.rb +36 -0
- data/app/models/smriti/application_record.rb +39 -0
- data/app/models/smriti/mat_view_definition.rb +254 -0
- data/app/models/smriti/mat_view_run.rb +275 -0
- data/app/views/layouts/smriti/_footer.html.erb +47 -0
- data/app/views/layouts/smriti/_header.html.erb +25 -0
- data/app/views/layouts/smriti/admin.html.erb +47 -0
- data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
- data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
- data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
- data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
- data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
- data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
- data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
- data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
- data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
- data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
- data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
- data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
- data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
- data/app/views/smriti/admin/preferences/show.html.erb +49 -0
- data/app/views/smriti/admin/ui/_card.html.erb +15 -0
- data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
- data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
- data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
- data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
- data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
- data/app/views/smriti/admin/ui/_details.html.erb +10 -0
- data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
- data/app/views/smriti/admin/ui/_table.html.erb +8 -0
- data/config/importmap.rb +9 -0
- data/config/locales/ar.yml +223 -0
- data/config/locales/de.yml +230 -0
- data/config/locales/en-AU-ocker.yml +223 -0
- data/config/locales/en-AU.yml +202 -0
- data/config/locales/en-BORK.yml +225 -0
- data/config/locales/en-CA.yml +223 -0
- data/config/locales/en-GB.yml +223 -0
- data/config/locales/en-LOL.yml +219 -0
- data/config/locales/en-SCOT.yml +223 -0
- data/config/locales/en-SHAKESPEARE.yml +225 -0
- data/config/locales/en-US-pirate.yml +222 -0
- data/config/locales/en-US.yml +225 -0
- data/config/locales/en-YODA.yml +221 -0
- data/config/locales/en.yml +223 -0
- data/config/locales/es.yml +226 -0
- data/config/locales/fa.yml +223 -0
- data/config/locales/fr-CA.yml +227 -0
- data/config/locales/fr.yml +227 -0
- data/config/locales/he.yml +218 -0
- data/config/locales/hi.yml +223 -0
- data/config/locales/it.yml +225 -0
- data/config/locales/ja-JP.yml +215 -0
- data/config/locales/pt.yml +225 -0
- data/config/locales/ru.yml +228 -0
- data/config/locales/ur.yml +225 -0
- data/config/locales/zh-CN.yml +214 -0
- data/config/locales/zh-TW.yml +214 -0
- data/config/routes.rb +36 -0
- data/lib/ext/exception.rb +20 -0
- data/lib/generators/smriti/install/install_generator.rb +86 -0
- data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
- data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
- data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
- data/lib/smriti/admin/auth_bridge.rb +93 -0
- data/lib/smriti/admin/default_auth.rb +62 -0
- data/lib/smriti/configuration.rb +58 -0
- data/lib/smriti/engine.rb +82 -0
- data/lib/smriti/helpers/ui_test_ids.rb +49 -0
- data/lib/smriti/jobs/adapter.rb +81 -0
- data/lib/smriti/service_response.rb +75 -0
- data/lib/smriti/services/base_service.rb +471 -0
- data/lib/smriti/services/check_matview_exists.rb +76 -0
- data/lib/smriti/services/concurrent_refresh.rb +94 -0
- data/lib/smriti/services/create_view.rb +173 -0
- data/lib/smriti/services/delete_view.rb +111 -0
- data/lib/smriti/services/regular_refresh.rb +90 -0
- data/lib/smriti/services/swap_refresh.rb +181 -0
- data/lib/smriti/version.rb +21 -0
- data/lib/smriti.rb +64 -0
- data/lib/tasks/helpers.rb +185 -0
- data/lib/tasks/smriti_tasks.rake +151 -0
- metadata +206 -0
|
@@ -0,0 +1,75 @@
|
|
|
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 Smriti
|
|
9
|
+
##
|
|
10
|
+
# Encapsulates the result of a service operation within Smriti.
|
|
11
|
+
#
|
|
12
|
+
# Provides a consistent contract for all services by standardizing:
|
|
13
|
+
# - `status`: Symbol representing outcome (`:ok`, `:created`, `:updated`,
|
|
14
|
+
# `:skipped`, `:deleted`, `:error`)
|
|
15
|
+
# - `request`: Request detailed that service was invoked with
|
|
16
|
+
# - `response`: Response detailed that service returned, nil with :error status
|
|
17
|
+
# - `error`: Exception or error message, with :error status
|
|
18
|
+
# - `message`: String description of the error
|
|
19
|
+
# - `class`: Exception class name
|
|
20
|
+
# - `backtrace`: Array of strings
|
|
21
|
+
#
|
|
22
|
+
# @example Successful response
|
|
23
|
+
# Smriti::ServiceResponse.new(
|
|
24
|
+
# status: :updated,
|
|
25
|
+
# response: { ... }
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# @example Error response
|
|
29
|
+
# Smriti::ServiceResponse.new(
|
|
30
|
+
# status: :error,
|
|
31
|
+
# error: StandardError.new("Something went wrong")
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
class ServiceResponse
|
|
35
|
+
attr_reader :status, :request, :error, :response
|
|
36
|
+
|
|
37
|
+
# acceptable status values
|
|
38
|
+
ACCEPTABLE_STATES = %i[ok created updated skipped deleted error].freeze
|
|
39
|
+
|
|
40
|
+
# statuses indicating success
|
|
41
|
+
OK_STATES = %i[ok created updated skipped deleted].freeze
|
|
42
|
+
|
|
43
|
+
# statuses indicating error
|
|
44
|
+
ERROR_STATES = %i[error].freeze
|
|
45
|
+
|
|
46
|
+
# @param status [Symbol] the outcome status
|
|
47
|
+
# @param request [Hash] request details
|
|
48
|
+
# @param response [Hash] response details
|
|
49
|
+
# @param error [Exception, String, nil] error details if applicable
|
|
50
|
+
def initialize(status:, request: {}, response: {}, error: nil)
|
|
51
|
+
raise ArgumentError, 'status is required' unless ACCEPTABLE_STATES.include?(status&.to_sym)
|
|
52
|
+
raise ArgumentError, 'error must be an Exception object' if error && !error.is_a?(Exception)
|
|
53
|
+
|
|
54
|
+
@status = status.to_sym
|
|
55
|
+
@request = request
|
|
56
|
+
@response = response
|
|
57
|
+
@error = error&.mv_serialize_error
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] whether the response represents a success
|
|
61
|
+
def success?
|
|
62
|
+
OK_STATES.include?(status)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether the response represents an error
|
|
66
|
+
def error?
|
|
67
|
+
ERROR_STATES.include?(status)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Hash] hash representation of the response
|
|
71
|
+
def to_h
|
|
72
|
+
{ status:, request:, response:, error: }.compact
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,471 @@
|
|
|
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 Smriti
|
|
9
|
+
module Services
|
|
10
|
+
##
|
|
11
|
+
# Base class for service objects that operate on PostgreSQL materialised
|
|
12
|
+
# views (create/refresh/delete, schema discovery, quoting, and common
|
|
13
|
+
# response helpers).
|
|
14
|
+
#
|
|
15
|
+
# Concrete services (e.g., {Smriti::Services::CreateView},
|
|
16
|
+
# {Smriti::Services::RegularRefresh}) should inherit from this class.
|
|
17
|
+
#
|
|
18
|
+
# @abstract
|
|
19
|
+
#
|
|
20
|
+
# @example Subclassing BaseService
|
|
21
|
+
# class MyService < Smriti::Services::BaseService
|
|
22
|
+
# private
|
|
23
|
+
# def assign_request
|
|
24
|
+
# # assign @request hash keys
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def prepare
|
|
28
|
+
# # perform pre-flight checks, raise StandardError on failure
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# def _run
|
|
32
|
+
# # perform the operation, return a Smriti::ServiceResponse
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class BaseService
|
|
37
|
+
# Constant indicating unknown row count
|
|
38
|
+
UNKNOWN_ROW_COUNT = -1
|
|
39
|
+
|
|
40
|
+
# Allowed row count strategies
|
|
41
|
+
ALLOWED_ROW_STRATEGIES = %i[none estimated exact].freeze
|
|
42
|
+
|
|
43
|
+
# Default row count strategy
|
|
44
|
+
DEFAULT_ROW_STRATEGY = :estimated
|
|
45
|
+
|
|
46
|
+
# Default strategy when nil or unrecognized value is given
|
|
47
|
+
DEFAULT_NIL_STRATEGY = :none
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# @return [Smriti::MatViewDefinition] The target materialised view definition.
|
|
51
|
+
attr_reader :definition
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Row count strategy (`:estimated`, `:exact`, `nil`).
|
|
55
|
+
#
|
|
56
|
+
# @return [Symbol, nil]
|
|
57
|
+
attr_reader :row_count_strategy
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# request hash to be returned in service response
|
|
61
|
+
# @return [Hash]
|
|
62
|
+
attr_accessor :request
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# response hash to be returned in service response
|
|
66
|
+
# @return [Hash]
|
|
67
|
+
attr_accessor :response
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# wrap in transaction
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
attr_accessor :use_transaction
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# @param definition [Smriti::MatViewDefinition]
|
|
76
|
+
# @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
|
|
77
|
+
#
|
|
78
|
+
def initialize(definition, row_count_strategy: DEFAULT_ROW_STRATEGY)
|
|
79
|
+
@definition = definition
|
|
80
|
+
@row_count_strategy = extract_row_strategy(row_count_strategy)
|
|
81
|
+
@request = {}
|
|
82
|
+
@response = {}
|
|
83
|
+
@use_transaction = true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Execute the service operation.
|
|
88
|
+
#
|
|
89
|
+
# Calls {#assign_request}, {#prepare} and {#_run} in order.
|
|
90
|
+
#
|
|
91
|
+
# Concrete subclasses must implement these methods.
|
|
92
|
+
#
|
|
93
|
+
# @return [Smriti::ServiceResponse]
|
|
94
|
+
# @raise [NotImplementedError] if not implemented in subclass
|
|
95
|
+
def call
|
|
96
|
+
if use_transaction
|
|
97
|
+
ActiveRecord::Base.transaction { run_core }
|
|
98
|
+
else
|
|
99
|
+
run_core
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
# finish pending transaction if any
|
|
103
|
+
# eg: current transaction is aborted, commands ignored until end of transaction block
|
|
104
|
+
error_response(e)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Core run logic without transaction wrapper.
|
|
111
|
+
# Called by {#call}.
|
|
112
|
+
#
|
|
113
|
+
# @api private
|
|
114
|
+
# @return [Smriti::ServiceResponse]
|
|
115
|
+
def run_core
|
|
116
|
+
assign_request
|
|
117
|
+
prepare
|
|
118
|
+
_run
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Assign the request parameters.
|
|
123
|
+
# Called by {#call} before {#prepare}.
|
|
124
|
+
#
|
|
125
|
+
# Must be implemented in concrete subclasses.
|
|
126
|
+
#
|
|
127
|
+
# @api private
|
|
128
|
+
# @return [void]
|
|
129
|
+
# @raise [NotImplementedError] if not implemented in subclass
|
|
130
|
+
#
|
|
131
|
+
def assign_request
|
|
132
|
+
raise NotImplementedError, "Must implement #{self.class}##{__method__}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
##
|
|
136
|
+
# Perform pre-flight checks.
|
|
137
|
+
# Called by {#call} after {#assign_request}.
|
|
138
|
+
#
|
|
139
|
+
# Must be implemented in concrete subclasses.
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
# @return [nil] on success
|
|
143
|
+
# @raise [StandardError] on failure
|
|
144
|
+
# @raise [NotImplementedError] if not implemented in subclass
|
|
145
|
+
#
|
|
146
|
+
def prepare
|
|
147
|
+
raise NotImplementedError, "Must implement #{self.class}##{__method__}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
##
|
|
151
|
+
# Execute the service operation.
|
|
152
|
+
# Called by {#call} after {#prepare}.
|
|
153
|
+
#
|
|
154
|
+
# Must be implemented in concrete subclasses.
|
|
155
|
+
#
|
|
156
|
+
# @api private
|
|
157
|
+
# @return [Smriti::ServiceResponse]
|
|
158
|
+
# @raise [NotImplementedError] if not implemented in subclass
|
|
159
|
+
#
|
|
160
|
+
def _run
|
|
161
|
+
raise NotImplementedError, "Must implement #{self.class}##{__method__}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def extract_row_strategy(value)
|
|
165
|
+
ALLOWED_ROW_STRATEGIES.include?(value) ? value : DEFAULT_NIL_STRATEGY
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ────────────────────────────────────────────────────────────────
|
|
169
|
+
# Schema / resolution helpers
|
|
170
|
+
# ────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
##
|
|
173
|
+
# Resolve the first existing schema from `schema_search_path`,
|
|
174
|
+
# falling back to `"public"` if none are valid.
|
|
175
|
+
#
|
|
176
|
+
# Supports `$user`, quoted tokens, and ignores non-existent schemas.
|
|
177
|
+
#
|
|
178
|
+
# @api private
|
|
179
|
+
# @return [String] a valid schema name
|
|
180
|
+
#
|
|
181
|
+
def first_existing_schema
|
|
182
|
+
raw_path = conn.schema_search_path.presence || 'public'
|
|
183
|
+
candidates = raw_path.split(',').filter_map { |token| resolve_schema_token(token.strip) }
|
|
184
|
+
candidates << 'public' unless candidates.include?('public')
|
|
185
|
+
candidates.find { |schema_str| schema_exists?(schema_str) } || 'public'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
##
|
|
189
|
+
# Normalize a schema token:
|
|
190
|
+
# - strip surrounding quotes
|
|
191
|
+
# - expand `$user` to the current database user
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @param token [String]
|
|
195
|
+
# @return [String]
|
|
196
|
+
#
|
|
197
|
+
def resolve_schema_token(token)
|
|
198
|
+
cleaned = token.delete_prefix('"').delete_suffix('"')
|
|
199
|
+
return current_user if cleaned == '$user'
|
|
200
|
+
|
|
201
|
+
cleaned
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
##
|
|
205
|
+
# @api private
|
|
206
|
+
# @return [String] current PostgreSQL user
|
|
207
|
+
def current_user
|
|
208
|
+
@current_user ||= conn.select_value('SELECT current_user')
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
##
|
|
212
|
+
# Check whether a schema exists.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
215
|
+
# @param name [String] schema name
|
|
216
|
+
# @return [Boolean]
|
|
217
|
+
#
|
|
218
|
+
def schema_exists?(name)
|
|
219
|
+
conn.select_value("SELECT to_regnamespace(#{conn.quote(name)}) IS NOT NULL")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ────────────────────────────────────────────────────────────────
|
|
223
|
+
# View / relation helpers
|
|
224
|
+
# ────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
##
|
|
227
|
+
# Whether the materialised view exists for the resolved `schema` and `rel`.
|
|
228
|
+
#
|
|
229
|
+
# @api private
|
|
230
|
+
# @return [Boolean]
|
|
231
|
+
#
|
|
232
|
+
def view_exists?
|
|
233
|
+
conn.select_value(<<~SQL).to_i.positive?
|
|
234
|
+
SELECT COUNT(*)
|
|
235
|
+
FROM pg_matviews
|
|
236
|
+
WHERE schemaname = #{conn.quote(schema)}
|
|
237
|
+
AND matviewname = #{conn.quote(rel)}
|
|
238
|
+
SQL
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
##
|
|
242
|
+
# Fully-qualified, safely-quoted relation name, e.g. `"public"."mv_users"`.
|
|
243
|
+
#
|
|
244
|
+
# @api private
|
|
245
|
+
# @return [String]
|
|
246
|
+
#
|
|
247
|
+
def qualified_rel
|
|
248
|
+
%(#{quote_table_name(schema)}.#{quote_table_name(rel)})
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
##
|
|
252
|
+
# Drop the materialised view if it exists (idempotent).
|
|
253
|
+
#
|
|
254
|
+
# @api private
|
|
255
|
+
# @return [void]
|
|
256
|
+
#
|
|
257
|
+
def drop_view
|
|
258
|
+
conn.execute(<<~SQL)
|
|
259
|
+
DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}
|
|
260
|
+
SQL
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
##
|
|
264
|
+
# Refresh strategy from the definition (stringified).
|
|
265
|
+
#
|
|
266
|
+
# @api private
|
|
267
|
+
# @return [String] one of `"regular"`, `"concurrent"`, `"swap"` (or custom)
|
|
268
|
+
#
|
|
269
|
+
def strategy
|
|
270
|
+
@strategy ||= definition.refresh_strategy.to_s
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
##
|
|
274
|
+
# Unqualified relation (matview) name from the definition.
|
|
275
|
+
#
|
|
276
|
+
# @api private
|
|
277
|
+
# @return [String]
|
|
278
|
+
#
|
|
279
|
+
def rel
|
|
280
|
+
@rel ||= definition.name.to_s
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
##
|
|
284
|
+
# SQL `SELECT …` for the materialization.
|
|
285
|
+
#
|
|
286
|
+
# @api private
|
|
287
|
+
# @return [String]
|
|
288
|
+
#
|
|
289
|
+
def sql
|
|
290
|
+
@sql ||= definition.sql.to_s
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
##
|
|
294
|
+
# Unique index column list (normalized to strings, unique).
|
|
295
|
+
#
|
|
296
|
+
# @api private
|
|
297
|
+
# @return [Array<String>]
|
|
298
|
+
#
|
|
299
|
+
def cols
|
|
300
|
+
@cols ||= Array(definition.unique_index_columns).map(&:to_s).uniq
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
##
|
|
304
|
+
# ActiveRecord connection.
|
|
305
|
+
#
|
|
306
|
+
# @api private
|
|
307
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
308
|
+
#
|
|
309
|
+
def conn
|
|
310
|
+
@conn ||= ActiveRecord::Base.connection
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
##
|
|
314
|
+
# Resolved, existing schema for this operation.
|
|
315
|
+
#
|
|
316
|
+
# @api private
|
|
317
|
+
# @return [String]
|
|
318
|
+
#
|
|
319
|
+
def schema
|
|
320
|
+
@schema ||= first_existing_schema
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# ────────────────────────────────────────────────────────────────
|
|
324
|
+
# Response helpers
|
|
325
|
+
# ────────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
##
|
|
328
|
+
# Build a success response.
|
|
329
|
+
#
|
|
330
|
+
# @api private
|
|
331
|
+
# @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:skipped`, `:deleted`
|
|
332
|
+
# @return [Smriti::ServiceResponse]
|
|
333
|
+
#
|
|
334
|
+
def ok(status)
|
|
335
|
+
Smriti::ServiceResponse.new(status:, request:, response:)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
##
|
|
339
|
+
# Raise a StandardError with the given message.
|
|
340
|
+
#
|
|
341
|
+
# @api private
|
|
342
|
+
# @param msg [String]
|
|
343
|
+
# @return [void]
|
|
344
|
+
# @raise [StandardError] with `msg`
|
|
345
|
+
#
|
|
346
|
+
def raise_err(msg)
|
|
347
|
+
raise StandardError, msg
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
##
|
|
351
|
+
# Build an error response from an exception, including backtrace.
|
|
352
|
+
#
|
|
353
|
+
# @api private
|
|
354
|
+
# @param error [Exception]
|
|
355
|
+
# @return [Smriti::ServiceResponse]
|
|
356
|
+
#
|
|
357
|
+
def error_response(error)
|
|
358
|
+
Smriti::ServiceResponse.new(status: :error, error:, request:, response:)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# ────────────────────────────────────────────────────────────────
|
|
362
|
+
# Quoting / environment helpers
|
|
363
|
+
# ────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
##
|
|
366
|
+
# Quote a column name for SQL.
|
|
367
|
+
#
|
|
368
|
+
# @api private
|
|
369
|
+
# @param name [String, Symbol]
|
|
370
|
+
# @return [String] quoted column name
|
|
371
|
+
#
|
|
372
|
+
def quote_column_name(name)
|
|
373
|
+
conn.quote_column_name(name)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
##
|
|
377
|
+
# Quote a table/relation name for SQL.
|
|
378
|
+
#
|
|
379
|
+
# @api private
|
|
380
|
+
# @param name [String, Symbol]
|
|
381
|
+
# @return [String] quoted relation name
|
|
382
|
+
#
|
|
383
|
+
def quote_table_name(name)
|
|
384
|
+
conn.quote_table_name(name)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
##
|
|
388
|
+
# Whether the underlying PG connection is idle (no active tx/savepoint).
|
|
389
|
+
#
|
|
390
|
+
# Used to guard `CONCURRENTLY` operations which must run outside a txn.
|
|
391
|
+
#
|
|
392
|
+
# @api private
|
|
393
|
+
# @return [Boolean]
|
|
394
|
+
#
|
|
395
|
+
def pg_idle?
|
|
396
|
+
rc = conn.raw_connection
|
|
397
|
+
return true unless rc.respond_to?(:transaction_status)
|
|
398
|
+
|
|
399
|
+
# Only use CONCURRENTLY outside any tx/savepoint.
|
|
400
|
+
rc.transaction_status == PG::PQTRANS_IDLE
|
|
401
|
+
rescue StandardError
|
|
402
|
+
false
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
##
|
|
406
|
+
# Check for any UNIQUE index on the materialised view, required by CONCURRENTLY.
|
|
407
|
+
#
|
|
408
|
+
# @api private
|
|
409
|
+
# @return [Boolean]
|
|
410
|
+
#
|
|
411
|
+
def unique_index_exists?
|
|
412
|
+
conn.select_value(<<~SQL).to_i.positive?
|
|
413
|
+
SELECT COUNT(*)
|
|
414
|
+
FROM pg_index i
|
|
415
|
+
JOIN pg_class c ON c.oid = i.indrelid
|
|
416
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
417
|
+
WHERE n.nspname = #{conn.quote(schema)}
|
|
418
|
+
AND c.relname = #{conn.quote(rel)}
|
|
419
|
+
AND i.indisunique = TRUE
|
|
420
|
+
SQL
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# ────────────────────────────────────────────────────────────────
|
|
424
|
+
# rows counting
|
|
425
|
+
# ────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
##
|
|
428
|
+
# Compute row count based on the configured strategy.
|
|
429
|
+
#
|
|
430
|
+
# @api private
|
|
431
|
+
# @return [Integer, nil]
|
|
432
|
+
#
|
|
433
|
+
def fetch_rows_count
|
|
434
|
+
case row_count_strategy
|
|
435
|
+
when :estimated then estimated_rows_count
|
|
436
|
+
when :exact then exact_rows_count
|
|
437
|
+
else
|
|
438
|
+
UNKNOWN_ROW_COUNT
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
##
|
|
443
|
+
# Fast, approximate row count via `pg_class.reltuples`.
|
|
444
|
+
#
|
|
445
|
+
# @api private
|
|
446
|
+
# @return [Integer]
|
|
447
|
+
#
|
|
448
|
+
def estimated_rows_count
|
|
449
|
+
conn.select_value(<<~SQL).to_i
|
|
450
|
+
SELECT COALESCE(c.reltuples::bigint, 0)
|
|
451
|
+
FROM pg_class c
|
|
452
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
453
|
+
WHERE c.relkind IN ('m','r','p')
|
|
454
|
+
AND n.nspname = #{conn.quote(schema)}
|
|
455
|
+
AND c.relname = #{conn.quote(rel)}
|
|
456
|
+
LIMIT 1
|
|
457
|
+
SQL
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
##
|
|
461
|
+
# Accurate row count using `COUNT(*)` on the materialised view.
|
|
462
|
+
#
|
|
463
|
+
# @api private
|
|
464
|
+
# @return [Integer]
|
|
465
|
+
#
|
|
466
|
+
def exact_rows_count
|
|
467
|
+
conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
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 Smriti
|
|
9
|
+
module Services
|
|
10
|
+
# Smriti::Services::CheckMatviewExists
|
|
11
|
+
# --------------------------------------
|
|
12
|
+
# Service object that checks whether the underlying PostgreSQL **materialised view**
|
|
13
|
+
# for a given {Smriti::MatViewDefinition} currently exists.
|
|
14
|
+
#
|
|
15
|
+
# ### Contract
|
|
16
|
+
# - Inherits from {Smriti::Services::BaseService}.
|
|
17
|
+
# - Uses BaseService helpers such as `definition`, `view_exists?`,
|
|
18
|
+
# `ok`, `raise_err`, and the `request`/`response` accessors.
|
|
19
|
+
# - The public entrypoint is `#call` (defined in BaseService), which will call the
|
|
20
|
+
# private lifecycle hooks here: {#prepare}, {#assign_request}, and {#_run}.
|
|
21
|
+
#
|
|
22
|
+
# ### Result
|
|
23
|
+
# - On success: status `:ok`, with `response: { exists: true|false }`.
|
|
24
|
+
# - On validation failure (bad view name): raises via {BaseService#raise_err}.
|
|
25
|
+
#
|
|
26
|
+
# @example Check if a materialised view exists
|
|
27
|
+
# defn = Smriti::MatViewDefinition.find(1)
|
|
28
|
+
# res = Smriti::Services::CheckMatviewExists.new(defn).call
|
|
29
|
+
# if res.success?
|
|
30
|
+
# puts res.response[:exists] ? "Exists" : "Missing"
|
|
31
|
+
# else
|
|
32
|
+
# warn res.error
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @see Smriti::Services::BaseService
|
|
36
|
+
# @see Smriti::MatViewDefinition
|
|
37
|
+
#
|
|
38
|
+
class CheckMatviewExists < BaseService
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Core execution step (invoked by BaseService#call).
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
44
|
+
#
|
|
45
|
+
# Sets {#response} to `{ exists: Boolean }` and marks the service as ok.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def _run
|
|
49
|
+
self.response = { exists: view_exists? }
|
|
50
|
+
ok(:ok)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
#
|
|
57
|
+
# Empty for this service as no other preparation is needed.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
def prepare; end
|
|
61
|
+
|
|
62
|
+
# Request initialization (invoked by BaseService#call).
|
|
63
|
+
#
|
|
64
|
+
# @api private
|
|
65
|
+
#
|
|
66
|
+
# Establishes a canonical, immutable snapshot of the input request
|
|
67
|
+
# for logging/inspection purposes. This service does not require inputs,
|
|
68
|
+
# so it assigns an empty Hash.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def assign_request
|
|
72
|
+
self.request = {}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
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 Smriti
|
|
9
|
+
module Services
|
|
10
|
+
##
|
|
11
|
+
# Refresh service that runs:
|
|
12
|
+
#
|
|
13
|
+
# `REFRESH MATERIALIZED VIEW CONCURRENTLY <schema>.<rel>`
|
|
14
|
+
#
|
|
15
|
+
# It keeps the view readable during refresh, but **requires at least one
|
|
16
|
+
# UNIQUE index** on the materialised view (a PostgreSQL constraint).
|
|
17
|
+
#
|
|
18
|
+
# Options:
|
|
19
|
+
# - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
|
|
20
|
+
#
|
|
21
|
+
# Returns a {Smriti::ServiceResponse}
|
|
22
|
+
#
|
|
23
|
+
# @see Smriti::Services::RegularRefresh
|
|
24
|
+
# @see Smriti::Services::SwapRefresh
|
|
25
|
+
#
|
|
26
|
+
# @example Direct usage
|
|
27
|
+
# svc = Smriti::Services::ConcurrentRefresh.new(definition, **options)
|
|
28
|
+
# response = svc.call
|
|
29
|
+
# response.success? # => true/false
|
|
30
|
+
#
|
|
31
|
+
# @example via job, this is the typical usage and will create a run record in the DB
|
|
32
|
+
# When definition.refresh_strategy == "concurrent"
|
|
33
|
+
# Smriti::Jobs::Adapter.enqueue(Smriti::Services::RefreshViewJob, definition.id, **options)
|
|
34
|
+
#
|
|
35
|
+
class ConcurrentRefresh < BaseService
|
|
36
|
+
def initialize(definition, row_count_strategy: :estimated)
|
|
37
|
+
super
|
|
38
|
+
@use_transaction = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Execute the concurrent refresh.
|
|
45
|
+
#
|
|
46
|
+
# Validates name format, existence of the matview, and presence of a UNIQUE index.
|
|
47
|
+
# If validation fails, returns an error {Smriti::ServiceResponse}.
|
|
48
|
+
#
|
|
49
|
+
# @return [Smriti::ServiceResponse]
|
|
50
|
+
# - `status: :updated` on success, with `response` containing:
|
|
51
|
+
# - `view` - the qualified view name
|
|
52
|
+
# - `row_count_before` - if requested and available
|
|
53
|
+
# - `row_count_after` - if requested and available
|
|
54
|
+
# - `status: :error` with `error` on failure, with `error` containing:
|
|
55
|
+
# - serlialized exception class, message, and backtrace in a hash
|
|
56
|
+
def _run
|
|
57
|
+
sql = "REFRESH MATERIALIZED VIEW CONCURRENTLY #{qualified_rel}"
|
|
58
|
+
self.response = { view: "#{schema}.#{rel}", sql: [sql] }
|
|
59
|
+
|
|
60
|
+
response[:row_count_before] = fetch_rows_count
|
|
61
|
+
conn.execute(sql)
|
|
62
|
+
response[:row_count_after] = fetch_rows_count
|
|
63
|
+
|
|
64
|
+
ok(:updated)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Assign the request parameters.
|
|
69
|
+
# Called by {#call} before {#prepare}.
|
|
70
|
+
# Sets `concurrent: true` in the request hash.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
def assign_request
|
|
76
|
+
self.request = { row_count_strategy: row_count_strategy, concurrent: true }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
# @return [nil] on success
|
|
84
|
+
# @raise [StandardError] on failure
|
|
85
|
+
#
|
|
86
|
+
def prepare
|
|
87
|
+
raise_err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
|
|
88
|
+
raise_err("Materialized view #{schema}.#{rel} must have a unique index for concurrent refresh") unless unique_index_exists?
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|