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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +168 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
  6. data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
  7. data/app/assets/images/smriti/apple-touch-icon.png +0 -0
  8. data/app/assets/images/smriti/favicon-16x16.png +0 -0
  9. data/app/assets/images/smriti/favicon-32x32.png +0 -0
  10. data/app/assets/images/smriti/favicon-48x48.png +0 -0
  11. data/app/assets/images/smriti/favicon.ico +0 -0
  12. data/app/assets/images/smriti/favicon.svg +18 -0
  13. data/app/assets/images/smriti/logo.svg +18 -0
  14. data/app/assets/images/smriti/mask-icon.svg +5 -0
  15. data/app/assets/stylesheets/smriti/application.css +1040 -0
  16. data/app/controllers/smriti/admin/application_controller.rb +135 -0
  17. data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
  18. data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
  19. data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
  20. data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
  21. data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
  22. data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
  23. data/app/helpers/smriti/admin/ui_helper.rb +539 -0
  24. data/app/javascript/smriti/application.js +8 -0
  25. data/app/javascript/smriti/controllers/application.js +10 -0
  26. data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
  27. data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
  28. data/app/javascript/smriti/controllers/details_controller.js +200 -0
  29. data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
  30. data/app/javascript/smriti/controllers/flash_controller.js +112 -0
  31. data/app/javascript/smriti/controllers/index.js +10 -0
  32. data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
  33. data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
  34. data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
  35. data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
  36. data/app/jobs/smriti/application_job.rb +144 -0
  37. data/app/jobs/smriti/create_view_job.rb +87 -0
  38. data/app/jobs/smriti/delete_view_job.rb +89 -0
  39. data/app/jobs/smriti/refresh_view_job.rb +94 -0
  40. data/app/models/concerns/smriti_i18n.rb +139 -0
  41. data/app/models/concerns/smriti_paginate.rb +70 -0
  42. data/app/models/concerns/smriti_query_helper.rb +36 -0
  43. data/app/models/smriti/application_record.rb +39 -0
  44. data/app/models/smriti/mat_view_definition.rb +254 -0
  45. data/app/models/smriti/mat_view_run.rb +275 -0
  46. data/app/views/layouts/smriti/_footer.html.erb +47 -0
  47. data/app/views/layouts/smriti/_header.html.erb +25 -0
  48. data/app/views/layouts/smriti/admin.html.erb +47 -0
  49. data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
  50. data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
  51. data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  52. data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
  53. data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
  54. data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
  55. data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
  56. data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
  57. data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
  58. data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
  59. data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
  60. data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
  61. data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
  62. data/app/views/smriti/admin/preferences/show.html.erb +49 -0
  63. data/app/views/smriti/admin/ui/_card.html.erb +15 -0
  64. data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
  65. data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
  66. data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
  67. data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
  68. data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
  69. data/app/views/smriti/admin/ui/_details.html.erb +10 -0
  70. data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
  71. data/app/views/smriti/admin/ui/_table.html.erb +8 -0
  72. data/config/importmap.rb +9 -0
  73. data/config/locales/ar.yml +223 -0
  74. data/config/locales/de.yml +230 -0
  75. data/config/locales/en-AU-ocker.yml +223 -0
  76. data/config/locales/en-AU.yml +202 -0
  77. data/config/locales/en-BORK.yml +225 -0
  78. data/config/locales/en-CA.yml +223 -0
  79. data/config/locales/en-GB.yml +223 -0
  80. data/config/locales/en-LOL.yml +219 -0
  81. data/config/locales/en-SCOT.yml +223 -0
  82. data/config/locales/en-SHAKESPEARE.yml +225 -0
  83. data/config/locales/en-US-pirate.yml +222 -0
  84. data/config/locales/en-US.yml +225 -0
  85. data/config/locales/en-YODA.yml +221 -0
  86. data/config/locales/en.yml +223 -0
  87. data/config/locales/es.yml +226 -0
  88. data/config/locales/fa.yml +223 -0
  89. data/config/locales/fr-CA.yml +227 -0
  90. data/config/locales/fr.yml +227 -0
  91. data/config/locales/he.yml +218 -0
  92. data/config/locales/hi.yml +223 -0
  93. data/config/locales/it.yml +225 -0
  94. data/config/locales/ja-JP.yml +215 -0
  95. data/config/locales/pt.yml +225 -0
  96. data/config/locales/ru.yml +228 -0
  97. data/config/locales/ur.yml +225 -0
  98. data/config/locales/zh-CN.yml +214 -0
  99. data/config/locales/zh-TW.yml +214 -0
  100. data/config/routes.rb +36 -0
  101. data/lib/ext/exception.rb +20 -0
  102. data/lib/generators/smriti/install/install_generator.rb +86 -0
  103. data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
  104. data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
  105. data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
  106. data/lib/smriti/admin/auth_bridge.rb +93 -0
  107. data/lib/smriti/admin/default_auth.rb +62 -0
  108. data/lib/smriti/configuration.rb +58 -0
  109. data/lib/smriti/engine.rb +82 -0
  110. data/lib/smriti/helpers/ui_test_ids.rb +49 -0
  111. data/lib/smriti/jobs/adapter.rb +81 -0
  112. data/lib/smriti/service_response.rb +75 -0
  113. data/lib/smriti/services/base_service.rb +471 -0
  114. data/lib/smriti/services/check_matview_exists.rb +76 -0
  115. data/lib/smriti/services/concurrent_refresh.rb +94 -0
  116. data/lib/smriti/services/create_view.rb +173 -0
  117. data/lib/smriti/services/delete_view.rb +111 -0
  118. data/lib/smriti/services/regular_refresh.rb +90 -0
  119. data/lib/smriti/services/swap_refresh.rb +181 -0
  120. data/lib/smriti/version.rb +21 -0
  121. data/lib/smriti.rb +64 -0
  122. data/lib/tasks/helpers.rb +185 -0
  123. data/lib/tasks/smriti_tasks.rake +151 -0
  124. 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