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,173 @@
|
|
|
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
|
+
# Service responsible for creating PostgreSQL materialised 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
|
+
# Options:
|
|
18
|
+
# - `force:` (Boolean, default: false) → drop and recreate if the view already exists
|
|
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::ConcurrentRefresh
|
|
25
|
+
#
|
|
26
|
+
# @example Create a new matview (no force)
|
|
27
|
+
# svc = Smriti::Services::CreateView.new(defn, **options)
|
|
28
|
+
# response = svc.call
|
|
29
|
+
# response.status # => :created or :skipped
|
|
30
|
+
#
|
|
31
|
+
# @example Force recreate an existing matview
|
|
32
|
+
# svc = Smriti::Services::CreateView.new(defn, force: true)
|
|
33
|
+
# svc.call
|
|
34
|
+
#
|
|
35
|
+
# @example via job, this is the typical usage and will create a run record in the DB
|
|
36
|
+
# Smriti::Jobs::Adapter.enqueue(Smriti::Services::CreateViewJob, definition.id, **options)
|
|
37
|
+
#
|
|
38
|
+
class CreateView < BaseService
|
|
39
|
+
##
|
|
40
|
+
# Whether to force recreation (drop+create if exists).
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
attr_reader :force
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @param definition [Smriti::MatViewDefinition]
|
|
47
|
+
# @param force [Boolean] Whether to drop+recreate an existing matview.
|
|
48
|
+
# @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
|
|
49
|
+
#
|
|
50
|
+
# Supports optional row count strategies:
|
|
51
|
+
# - `:estimated` → approximate, using `pg_class.reltuples`
|
|
52
|
+
# - `:exact` → accurate, using `COUNT(*)`
|
|
53
|
+
# - `nil` → skip row count
|
|
54
|
+
def initialize(definition, force: false, row_count_strategy: :estimated)
|
|
55
|
+
super(definition, row_count_strategy: row_count_strategy)
|
|
56
|
+
@force = force
|
|
57
|
+
# Transactions are disabled if unique_index_columns are present because
|
|
58
|
+
# PostgreSQL does not allow creating a UNIQUE INDEX CONCURRENTLY inside a transaction block.
|
|
59
|
+
# If a unique index is required (for concurrent refresh), we must avoid wrapping the operation in a transaction.
|
|
60
|
+
@use_transaction = definition.unique_index_columns.none?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Execute the create operation.
|
|
67
|
+
#
|
|
68
|
+
# - Validates name, SQL, and concurrent-index requirements.
|
|
69
|
+
# - Handles existing view: skipped (default) or drop+recreate (`force: true`).
|
|
70
|
+
# - Creates the materialised view WITH DATA.
|
|
71
|
+
# - Creates a UNIQUE index if refresh strategy is concurrent.
|
|
72
|
+
#
|
|
73
|
+
# @api private
|
|
74
|
+
#
|
|
75
|
+
# @return [Smriti::ServiceResponse]
|
|
76
|
+
# - `status: :created or :skipped` on success, with `response` containing:
|
|
77
|
+
# - `view` - the qualified view name
|
|
78
|
+
# - `row_count_before` - if requested and available
|
|
79
|
+
# - `row_count_after` - if requested and available
|
|
80
|
+
# - `status: :error` with `error` on failure, with `error` containing:
|
|
81
|
+
# - serlialized exception class, message, and backtrace in a hash
|
|
82
|
+
def _run
|
|
83
|
+
sql = create_with_data_sql
|
|
84
|
+
self.response = { view: "#{schema}.#{rel}", sql: [sql] }
|
|
85
|
+
# If exists, either skipped or drop+recreate
|
|
86
|
+
existed = handle_existing
|
|
87
|
+
return existed if existed.is_a?(Smriti::ServiceResponse)
|
|
88
|
+
|
|
89
|
+
response[:row_count_before] = UNKNOWN_ROW_COUNT
|
|
90
|
+
conn.execute(sql)
|
|
91
|
+
response[:row_count_after] = fetch_rows_count
|
|
92
|
+
|
|
93
|
+
# For concurrent strategy, ensure the unique index so future
|
|
94
|
+
# REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
|
|
95
|
+
response.merge!(ensure_unique_index_if_needed)
|
|
96
|
+
|
|
97
|
+
ok(:created)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
102
|
+
# Empty for this service as no other preparation is needed.
|
|
103
|
+
#
|
|
104
|
+
# @api private
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
#
|
|
108
|
+
def prepare; end
|
|
109
|
+
|
|
110
|
+
##
|
|
111
|
+
# Assign the request parameters.
|
|
112
|
+
# Called by {#call} before {#prepare}.
|
|
113
|
+
#
|
|
114
|
+
# @api private
|
|
115
|
+
# @return [void]
|
|
116
|
+
#
|
|
117
|
+
def assign_request
|
|
118
|
+
self.request = { row_count_strategy: row_count_strategy, force: }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Handle existing matview: return skipped if not forcing, or drop if forcing.
|
|
123
|
+
#
|
|
124
|
+
# @api private
|
|
125
|
+
# @return [Smriti::ServiceResponse, nil]
|
|
126
|
+
#
|
|
127
|
+
def handle_existing
|
|
128
|
+
return nil unless view_exists?
|
|
129
|
+
|
|
130
|
+
return ok(:skipped) unless force
|
|
131
|
+
|
|
132
|
+
drop_view
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# SQL for `CREATE MATERIALIZED VIEW ... WITH DATA`.
|
|
138
|
+
# @api private
|
|
139
|
+
# @return [String]
|
|
140
|
+
#
|
|
141
|
+
def create_with_data_sql
|
|
142
|
+
<<~SQL
|
|
143
|
+
CREATE MATERIALIZED VIEW #{qualified_rel} AS
|
|
144
|
+
#{sql}
|
|
145
|
+
WITH DATA
|
|
146
|
+
SQL
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
##
|
|
150
|
+
# Ensure a UNIQUE index if refresh strategy is concurrent.
|
|
151
|
+
#
|
|
152
|
+
# Builds an index name like `public_mvname_uniq_col1_col2`.
|
|
153
|
+
# Creates it concurrently if the PG connection is idle.
|
|
154
|
+
#
|
|
155
|
+
# @api private
|
|
156
|
+
# @return [Hash] `{ created_indexes: [String] }` or empty array if not needed
|
|
157
|
+
#
|
|
158
|
+
def ensure_unique_index_if_needed
|
|
159
|
+
return { created_indexes: [] } unless strategy == 'concurrent'
|
|
160
|
+
|
|
161
|
+
# Name like: public_mvname_uniq_col1_col2
|
|
162
|
+
idx_name = [schema, rel, 'uniq', *cols].join('_')
|
|
163
|
+
|
|
164
|
+
concurrently = pg_idle?
|
|
165
|
+
conn.execute(<<~SQL)
|
|
166
|
+
CREATE UNIQUE INDEX #{'CONCURRENTLY ' if concurrently}#{quote_table_name(idx_name)}
|
|
167
|
+
ON #{qualified_rel} (#{cols.map { |col| quote_column_name(col) }.join(', ')})
|
|
168
|
+
SQL
|
|
169
|
+
{ created_indexes: [idx_name], row_count_before: UNKNOWN_ROW_COUNT, row_count_after: fetch_rows_count }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
# Service that safely drops a PostgreSQL materialised view.
|
|
12
|
+
#
|
|
13
|
+
# Options:
|
|
14
|
+
# - `cascade:` (Boolean, default: false) → drop with CASCADE instead of RESTRICT
|
|
15
|
+
# - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
|
|
16
|
+
#
|
|
17
|
+
# Returns a {Smriti::ServiceResponse}
|
|
18
|
+
#
|
|
19
|
+
# @see Smriti::DeleteViewJob
|
|
20
|
+
# @see Smriti::MatViewRun
|
|
21
|
+
#
|
|
22
|
+
# @example Drop a view if it exists
|
|
23
|
+
# svc = Smriti::Services::DeleteView.new(defn, **options)
|
|
24
|
+
# svc.call
|
|
25
|
+
#
|
|
26
|
+
# @example Force drop with CASCADE
|
|
27
|
+
# Smriti::Services::DeleteView.new(defn, cascade: true).call
|
|
28
|
+
#
|
|
29
|
+
# @example via job, this is the typical usage and will create a run record in the DB
|
|
30
|
+
# Smriti::Jobs::Adapter.enqueue(Smriti::Services::DeleteViewJob, definition.id, **options)
|
|
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
|
+
# @param definition [Smriti::MatViewDefinition]
|
|
41
|
+
# @param cascade [Boolean] drop with CASCADE instead of RESTRICT
|
|
42
|
+
# @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
|
|
43
|
+
def initialize(definition, cascade: false, row_count_strategy: :estimated)
|
|
44
|
+
super(definition, row_count_strategy: row_count_strategy)
|
|
45
|
+
@cascade = cascade ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# Run the drop operation.
|
|
52
|
+
#
|
|
53
|
+
# Steps:
|
|
54
|
+
# - Validate name format
|
|
55
|
+
# - return :skipped if absent
|
|
56
|
+
# - Execute DROP MATERIALIZED VIEW.
|
|
57
|
+
#
|
|
58
|
+
# @api private
|
|
59
|
+
#
|
|
60
|
+
# @return [Smriti::ServiceResponse]
|
|
61
|
+
# - `status: :deleted or :skipped` on success, with `response` containing:
|
|
62
|
+
# - `view` - the qualified view name
|
|
63
|
+
# - `row_count_before` - if requested and available
|
|
64
|
+
# - `row_count_after` - if requested and available
|
|
65
|
+
# - `status: :error` with `error` on failure, with `error` containing:
|
|
66
|
+
# - serlialized exception class, message, and backtrace in a hash
|
|
67
|
+
def _run
|
|
68
|
+
self.response = { view: "#{schema}.#{rel}", sql: [drop_sql] }
|
|
69
|
+
|
|
70
|
+
return ok(:skipped) unless view_exists?
|
|
71
|
+
|
|
72
|
+
response[:row_count_before] = fetch_rows_count
|
|
73
|
+
conn.execute(drop_sql)
|
|
74
|
+
response[:row_count_after] = UNKNOWN_ROW_COUNT # view is gone
|
|
75
|
+
ok(:deleted)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# Assign the request parameters.
|
|
80
|
+
# Called by {#call} before {#prepare}.
|
|
81
|
+
# Sets `concurrent: true` in the request hash.
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @return [void]
|
|
85
|
+
#
|
|
86
|
+
def assign_request
|
|
87
|
+
self.request = { row_count_strategy: row_count_strategy, cascade: cascade }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
92
|
+
# Empty for this service as no other preparation is needed.
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
def prepare; end
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# Build the SQL DROP statement.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
103
|
+
# @return [String]
|
|
104
|
+
#
|
|
105
|
+
def drop_sql
|
|
106
|
+
drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
|
|
107
|
+
%(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
# 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
|
+
# Options:
|
|
17
|
+
# - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
|
|
18
|
+
#
|
|
19
|
+
# Returns a {Smriti::ServiceResponse}
|
|
20
|
+
#
|
|
21
|
+
# @see Smriti::Services::ConcurrentRefresh
|
|
22
|
+
# @see Smriti::Services::SwapRefresh
|
|
23
|
+
#
|
|
24
|
+
# @example Direct usage
|
|
25
|
+
# svc = Smriti::Services::RegularRefresh.new(definition, **options)
|
|
26
|
+
# response = svc.call
|
|
27
|
+
# response.success? # => true/false
|
|
28
|
+
#
|
|
29
|
+
# @example via job, this is the typical usage and will create a run record in the DB
|
|
30
|
+
# When definition.refresh_strategy == "concurrent"
|
|
31
|
+
# Smriti::Jobs::Adapter.enqueue(Smriti::Services::RegularRefresh, definition.id, **options)
|
|
32
|
+
#
|
|
33
|
+
class RegularRefresh < BaseService
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Perform the refresh.
|
|
38
|
+
#
|
|
39
|
+
# Steps:
|
|
40
|
+
# - Validate name & existence.
|
|
41
|
+
# - Run `REFRESH MATERIALIZED VIEW`.
|
|
42
|
+
# - Optionally compute row count.
|
|
43
|
+
#
|
|
44
|
+
# @return [Smriti::ServiceResponse]
|
|
45
|
+
# - `status: :updated` on success, with `response` containing:
|
|
46
|
+
# - `view` - the qualified view name
|
|
47
|
+
# - `row_count_before` - if requested and available
|
|
48
|
+
# - `row_count_after` - if requested and available
|
|
49
|
+
# - `status: :error` with `error` on failure, with `error` containing:
|
|
50
|
+
# - serlialized exception class, message, and backtrace in a hash
|
|
51
|
+
#
|
|
52
|
+
def _run
|
|
53
|
+
sql = "REFRESH MATERIALIZED VIEW #{qualified_rel}"
|
|
54
|
+
|
|
55
|
+
self.response = { view: "#{schema}.#{rel}", sql: [sql] }
|
|
56
|
+
|
|
57
|
+
response[:row_count_before] = fetch_rows_count
|
|
58
|
+
conn.execute(sql)
|
|
59
|
+
response[:row_count_after] = fetch_rows_count
|
|
60
|
+
|
|
61
|
+
ok(:updated)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
66
|
+
# Ensures view exists.
|
|
67
|
+
#
|
|
68
|
+
# @api private
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
#
|
|
72
|
+
def prepare
|
|
73
|
+
raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
|
|
74
|
+
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# Assign the request parameters.
|
|
80
|
+
# Called by {#call} before {#prepare}.
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
# @return [void]
|
|
84
|
+
#
|
|
85
|
+
def assign_request
|
|
86
|
+
self.request = { row_count_strategy: row_count_strategy }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
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 Smriti
|
|
11
|
+
module Services
|
|
12
|
+
##
|
|
13
|
+
# Service that performs a **swap-style refresh** of a materialised view.
|
|
14
|
+
#
|
|
15
|
+
# Instead of locking the existing view, this strategy builds a new
|
|
16
|
+
# temporary materialised 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
|
+
# Options:
|
|
25
|
+
# - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
|
|
26
|
+
#
|
|
27
|
+
# Returns a {Smriti::ServiceResponse}
|
|
28
|
+
#
|
|
29
|
+
# @see Smriti::Services::ConcurrentRefresh
|
|
30
|
+
# @see Smriti::Services::RegularRefresh
|
|
31
|
+
#
|
|
32
|
+
# @example Direct usage
|
|
33
|
+
# svc = Smriti::Services::SwapRefresh.new(definition, **options)
|
|
34
|
+
# response = svc.call
|
|
35
|
+
# response.success? # => true/false
|
|
36
|
+
#
|
|
37
|
+
# @example via job, this is the typical usage and will create a run record in the DB
|
|
38
|
+
# When definition.refresh_strategy == "concurrent"
|
|
39
|
+
# Smriti::Jobs::Adapter.enqueue(Smriti::Services::SwapRefresh, definition.id, **options)
|
|
40
|
+
#
|
|
41
|
+
class SwapRefresh < BaseService
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Execute the swap refresh.
|
|
46
|
+
#
|
|
47
|
+
# @return [Smriti::ServiceResponse]
|
|
48
|
+
# - `status: :updated` on success, with `response` containing:
|
|
49
|
+
# - `view` - the qualified view name
|
|
50
|
+
# - `row_count_before` - if requested and available
|
|
51
|
+
# - `row_count_after` - if requested and available
|
|
52
|
+
# - `status: :error` with `error` on failure, with `error` containing:
|
|
53
|
+
# - serlialized exception class, message, and backtrace in a hash
|
|
54
|
+
#
|
|
55
|
+
def _run
|
|
56
|
+
self.response = { view: "#{schema}.#{rel}" }
|
|
57
|
+
|
|
58
|
+
response[:row_count_before] = fetch_rows_count
|
|
59
|
+
response[:sql] = swap_view
|
|
60
|
+
response[:row_count_after] = fetch_rows_count
|
|
61
|
+
|
|
62
|
+
ok(:updated)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Validation step (invoked by BaseService#call before execution).
|
|
67
|
+
# Ensures view exists.
|
|
68
|
+
#
|
|
69
|
+
# @api private
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
#
|
|
73
|
+
def prepare
|
|
74
|
+
raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
|
|
75
|
+
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# Assign the request parameters.
|
|
81
|
+
# Called by {#call} before {#prepare}.
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @return [void]
|
|
85
|
+
#
|
|
86
|
+
def assign_request
|
|
87
|
+
self.request = { row_count_strategy: row_count_strategy, swap: true }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Perform rename/drop/index recreation in a transaction.
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
# @return [Array<String>] SQL steps executed
|
|
95
|
+
def swap_view
|
|
96
|
+
conn.execute(create_temp_view_sql)
|
|
97
|
+
steps = [
|
|
98
|
+
move_current_to_old_sql,
|
|
99
|
+
move_temp_to_current_sql,
|
|
100
|
+
drop_old_view_sql,
|
|
101
|
+
recreate_declared_unique_indexes_sql
|
|
102
|
+
].compact
|
|
103
|
+
conn.transaction do
|
|
104
|
+
steps.each { |step| conn.execute(step) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# prepend the create step
|
|
108
|
+
steps.unshift(create_temp_view_sql)
|
|
109
|
+
steps
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def create_temp_view_sql
|
|
113
|
+
@create_temp_view_sql ||= %(CREATE MATERIALIZED VIEW #{q_tmp} AS #{definition.sql} WITH DATA)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def move_current_to_old_sql
|
|
117
|
+
%(ALTER MATERIALIZED VIEW #{qualified_rel} RENAME TO #{conn.quote_column_name(old_rel)})
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def move_temp_to_current_sql
|
|
121
|
+
%(ALTER MATERIALIZED VIEW #{q_tmp} RENAME TO #{conn.quote_column_name(rel)})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def drop_old_view_sql
|
|
125
|
+
%(DROP MATERIALIZED VIEW #{q_old})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# Quote the temporary materialised view name.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @return [String] quoted temporary view name
|
|
133
|
+
def q_tmp
|
|
134
|
+
@q_tmp ||= conn.quote_table_name("#{schema}.#{tmp_rel}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
##
|
|
138
|
+
# Quote the original materialised view name.
|
|
139
|
+
#
|
|
140
|
+
# @api private
|
|
141
|
+
# @return [String] quoted original view name
|
|
142
|
+
def q_old
|
|
143
|
+
@q_old ||= conn.quote_table_name("#{schema}.#{old_rel}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# Fully-qualified, safely-quoted temporary relation name.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
# @return [String]
|
|
151
|
+
def tmp_rel
|
|
152
|
+
@tmp_rel ||= "#{rel}__tmp_#{SecureRandom.hex(4)}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
##
|
|
156
|
+
# Fully-qualified, safely-quoted old relation name.
|
|
157
|
+
#
|
|
158
|
+
# @api private
|
|
159
|
+
# @return [String]
|
|
160
|
+
def old_rel
|
|
161
|
+
@old_rel ||= "#{rel}__old_#{SecureRandom.hex(4)}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
##
|
|
165
|
+
# Recreate declared unique indexes on the swapped-in view.
|
|
166
|
+
#
|
|
167
|
+
# @api private
|
|
168
|
+
# @return [String] SQL statements to execute
|
|
169
|
+
def recreate_declared_unique_indexes_sql
|
|
170
|
+
cols = Array(definition.unique_index_columns).map(&:to_s).reject(&:empty?)
|
|
171
|
+
return nil if cols.empty?
|
|
172
|
+
|
|
173
|
+
quoted_cols = cols.map { |col| conn.quote_column_name(col) }.join(', ')
|
|
174
|
+
idx_name = conn.quote_column_name("#{rel}_uniq_#{cols.join('_')}")
|
|
175
|
+
q_rel = conn.quote_table_name("#{schema}.#{rel}")
|
|
176
|
+
|
|
177
|
+
%(CREATE UNIQUE INDEX #{idx_name} ON #{q_rel} (#{quoted_cols}))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
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 Smriti
|
|
9
|
+
##
|
|
10
|
+
# Defines the version of the Smriti 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.5.0'
|
|
21
|
+
end
|
data/lib/smriti.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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 'ext/exception'
|
|
9
|
+
require 'smriti/version'
|
|
10
|
+
require 'smriti/engine'
|
|
11
|
+
require 'smriti/helpers/ui_test_ids'
|
|
12
|
+
require 'smriti/configuration'
|
|
13
|
+
require 'smriti/jobs/adapter'
|
|
14
|
+
require 'smriti/service_response'
|
|
15
|
+
require 'smriti/services/base_service'
|
|
16
|
+
require 'smriti/services/create_view'
|
|
17
|
+
require 'smriti/services/regular_refresh'
|
|
18
|
+
require 'smriti/services/concurrent_refresh'
|
|
19
|
+
require 'smriti/services/swap_refresh'
|
|
20
|
+
require 'smriti/services/delete_view'
|
|
21
|
+
require 'smriti/services/check_matview_exists'
|
|
22
|
+
require 'smriti/admin/auth_bridge'
|
|
23
|
+
require 'smriti/admin/default_auth'
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Smriti is a Rails engine that provides first-class support for
|
|
27
|
+
# PostgreSQL materialised views in Rails applications.
|
|
28
|
+
#
|
|
29
|
+
# Features include:
|
|
30
|
+
# - Declarative definitions for materialised views
|
|
31
|
+
# - Safe creation, refresh (regular, concurrent, swap), and deletion
|
|
32
|
+
# - Background job integration (ActiveJob, Sidekiq, Resque)
|
|
33
|
+
# - Tracking of run history and metrics
|
|
34
|
+
# - Rake task helpers for operational workflows
|
|
35
|
+
#
|
|
36
|
+
# Usage:
|
|
37
|
+
# Smriti.configure do |config|
|
|
38
|
+
# config.job_queue = :low_priority
|
|
39
|
+
# config.job_adapter = :sidekiq
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# Once mounted, Rails apps can leverage Smriti services and jobs
|
|
43
|
+
# to manage materialised views consistently.
|
|
44
|
+
module Smriti
|
|
45
|
+
class << self
|
|
46
|
+
# Global configuration for Smriti
|
|
47
|
+
# @return [Smriti::Configuration]
|
|
48
|
+
attr_accessor :configuration
|
|
49
|
+
|
|
50
|
+
# Configure Smriti via block.
|
|
51
|
+
#
|
|
52
|
+
# Example:
|
|
53
|
+
# Smriti.configure do |config|
|
|
54
|
+
# config.job_adapter = :sidekiq
|
|
55
|
+
# config.job_queue = :materialised
|
|
56
|
+
# end
|
|
57
|
+
def configure
|
|
58
|
+
@configuration ||= Configuration.new
|
|
59
|
+
yield(configuration)
|
|
60
|
+
|
|
61
|
+
configuration.admin_ui[:row_count_strategy] ||= :none
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|