mat_views 0.1.2 → 0.3.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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/app/assets/images/mat_views/android-chrome-192x192.png +0 -0
  4. data/app/assets/images/mat_views/android-chrome-512x512.png +0 -0
  5. data/app/assets/images/mat_views/apple-touch-icon.png +0 -0
  6. data/app/assets/images/mat_views/favicon-16x16.png +0 -0
  7. data/app/assets/images/mat_views/favicon-32x32.png +0 -0
  8. data/app/assets/images/mat_views/favicon-48x48.png +0 -0
  9. data/app/assets/images/mat_views/favicon.ico +0 -0
  10. data/app/assets/images/mat_views/favicon.svg +18 -0
  11. data/app/assets/images/mat_views/logo.svg +18 -0
  12. data/app/assets/images/mat_views/mask-icon.svg +5 -0
  13. data/app/assets/stylesheets/mat_views/application.css +323 -12
  14. data/app/controllers/mat_views/admin/application_controller.rb +135 -0
  15. data/app/controllers/mat_views/admin/dashboard_controller.rb +32 -0
  16. data/app/controllers/mat_views/admin/mat_view_definitions_controller.rb +248 -0
  17. data/app/controllers/mat_views/admin/preferences_controller.rb +91 -0
  18. data/app/controllers/mat_views/admin/runs_controller.rb +74 -0
  19. data/app/helpers/mat_views/admin/ui_helper.rb +385 -0
  20. data/app/javascript/mat_views/application.js +8 -0
  21. data/app/javascript/mat_views/controllers/application.js +10 -0
  22. data/app/javascript/mat_views/controllers/details_controller.js +122 -0
  23. data/app/javascript/mat_views/controllers/drawer_controller.js +252 -0
  24. data/app/javascript/mat_views/controllers/filter_controller.js +90 -0
  25. data/app/javascript/mat_views/controllers/flash_controller.js +13 -0
  26. data/app/javascript/mat_views/controllers/index.js +10 -0
  27. data/app/javascript/mat_views/controllers/mv_confirm_controller.js +281 -0
  28. data/app/javascript/mat_views/controllers/submitter_controller.js +15 -0
  29. data/app/javascript/mat_views/controllers/tabs_controller.js +67 -0
  30. data/app/javascript/mat_views/controllers/timezone_controller.js +16 -0
  31. data/app/javascript/mat_views/controllers/tooltip_controller.js +328 -0
  32. data/app/javascript/mat_views/controllers/turbo_frame_lifecycle_controller.js +49 -0
  33. data/app/jobs/mat_views/application_job.rb +107 -2
  34. data/app/jobs/mat_views/create_view_job.rb +21 -122
  35. data/app/jobs/mat_views/delete_view_job.rb +22 -129
  36. data/app/jobs/mat_views/refresh_view_job.rb +12 -133
  37. data/app/models/concerns/mat_views_i18n.rb +139 -0
  38. data/app/models/mat_views/application_record.rb +1 -0
  39. data/app/models/mat_views/mat_view_definition.rb +12 -7
  40. data/app/models/mat_views/mat_view_run.rb +34 -16
  41. data/app/views/layouts/mat_views/_footer.html.erb +41 -0
  42. data/app/views/layouts/mat_views/_header.html.erb +25 -0
  43. data/app/views/layouts/mat_views/admin.html.erb +47 -0
  44. data/app/views/layouts/mat_views/turbo_frame.html.erb +3 -0
  45. data/app/views/mat_views/admin/dashboard/index.html.erb +33 -0
  46. data/app/views/mat_views/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  47. data/app/views/mat_views/admin/mat_view_definitions/_table.html.erb +48 -0
  48. data/app/views/mat_views/admin/mat_view_definitions/empty.html.erb +1 -0
  49. data/app/views/mat_views/admin/mat_view_definitions/form.html.erb +79 -0
  50. data/app/views/mat_views/admin/mat_view_definitions/index.html.erb +10 -0
  51. data/app/views/mat_views/admin/mat_view_definitions/show.html.erb +40 -0
  52. data/app/views/mat_views/admin/preferences/show.html.erb +50 -0
  53. data/app/views/mat_views/admin/runs/_table.html.erb +61 -0
  54. data/app/views/mat_views/admin/runs/index.html.erb +38 -0
  55. data/app/views/mat_views/admin/runs/show.html.erb +64 -0
  56. data/app/views/mat_views/admin/ui/_card.html.erb +15 -0
  57. data/app/views/mat_views/admin/ui/_details.html.erb +10 -0
  58. data/app/views/mat_views/admin/ui/_flash.html.erb +6 -0
  59. data/app/views/mat_views/admin/ui/_table.html.erb +8 -0
  60. data/config/importmap.rb +9 -0
  61. data/config/locales/en-AU-ocker.yml +187 -0
  62. data/config/locales/en-AU.yml +187 -0
  63. data/config/locales/en-BB.yml +187 -0
  64. data/config/locales/en-BD.yml +187 -0
  65. data/config/locales/en-BE.yml +187 -0
  66. data/config/locales/en-BORK.yml +187 -0
  67. data/config/locales/en-BS.yml +187 -0
  68. data/config/locales/en-BZ.yml +187 -0
  69. data/config/locales/en-CA.yml +187 -0
  70. data/config/locales/en-CM.yml +187 -0
  71. data/config/locales/en-CY.yml +187 -0
  72. data/config/locales/en-EG.yml +187 -0
  73. data/config/locales/en-FJ.yml +187 -0
  74. data/config/locales/en-GB.yml +187 -0
  75. data/config/locales/en-GH.yml +187 -0
  76. data/config/locales/en-GI.yml +187 -0
  77. data/config/locales/en-GM.yml +187 -0
  78. data/config/locales/en-GY.yml +187 -0
  79. data/config/locales/en-HK.yml +187 -0
  80. data/config/locales/en-IE.yml +187 -0
  81. data/config/locales/en-IN.yml +187 -0
  82. data/config/locales/en-JM.yml +187 -0
  83. data/config/locales/en-KE.yml +187 -0
  84. data/config/locales/en-LK.yml +187 -0
  85. data/config/locales/en-LOL.yml +187 -0
  86. data/config/locales/en-LR.yml +187 -0
  87. data/config/locales/en-MS.yml +187 -0
  88. data/config/locales/en-MT.yml +187 -0
  89. data/config/locales/en-MW.yml +187 -0
  90. data/config/locales/en-MY.yml +187 -0
  91. data/config/locales/en-NG.yml +187 -0
  92. data/config/locales/en-NP.yml +187 -0
  93. data/config/locales/en-NZ.yml +187 -0
  94. data/config/locales/en-PG.yml +187 -0
  95. data/config/locales/en-PH.yml +187 -0
  96. data/config/locales/en-PK.yml +187 -0
  97. data/config/locales/en-RW.yml +187 -0
  98. data/config/locales/en-SCOT.yml +187 -0
  99. data/config/locales/en-SG.yml +187 -0
  100. data/config/locales/en-SHAKESPEARE.yml +187 -0
  101. data/config/locales/en-SL.yml +187 -0
  102. data/config/locales/en-SS.yml +187 -0
  103. data/config/locales/en-TH.yml +187 -0
  104. data/config/locales/en-TT.yml +187 -0
  105. data/config/locales/en-TZ.yml +187 -0
  106. data/config/locales/en-UG.yml +187 -0
  107. data/config/locales/en-US-pirate.yml +187 -0
  108. data/config/locales/en-US.yml +187 -0
  109. data/config/locales/en-YODA.yml +187 -0
  110. data/config/locales/en-ZA.yml +187 -0
  111. data/config/locales/en-ZW.yml +187 -0
  112. data/config/locales/en.yml +187 -0
  113. data/config/routes.rb +27 -3
  114. data/lib/ext/exception.rb +20 -0
  115. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +7 -7
  116. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +7 -7
  117. data/lib/mat_views/admin/auth_bridge.rb +93 -0
  118. data/lib/mat_views/admin/default_auth.rb +61 -0
  119. data/lib/mat_views/configuration.rb +9 -0
  120. data/lib/mat_views/engine.rb +50 -2
  121. data/lib/mat_views/helpers/ui_test_ids.rb +43 -0
  122. data/lib/mat_views/jobs/adapter.rb +8 -5
  123. data/lib/mat_views/service_response.rb +30 -15
  124. data/lib/mat_views/services/base_service.rb +204 -41
  125. data/lib/mat_views/services/check_matview_exists.rb +76 -0
  126. data/lib/mat_views/services/concurrent_refresh.rb +38 -121
  127. data/lib/mat_views/services/create_view.rb +72 -55
  128. data/lib/mat_views/services/delete_view.rb +46 -95
  129. data/lib/mat_views/services/regular_refresh.rb +38 -94
  130. data/lib/mat_views/services/swap_refresh.rb +83 -123
  131. data/lib/mat_views/version.rb +1 -1
  132. data/lib/mat_views.rb +13 -6
  133. data/lib/tasks/helpers.rb +27 -27
  134. data/lib/tasks/mat_views_tasks.rake +48 -42
  135. metadata +131 -5
@@ -8,7 +8,7 @@
8
8
  module MatViews
9
9
  module Services
10
10
  ##
11
- # Base class for service objects that operate on PostgreSQL materialized
11
+ # Base class for service objects that operate on PostgreSQL materialised
12
12
  # views (create/refresh/delete, schema discovery, quoting, and common
13
13
  # response helpers).
14
14
  #
@@ -19,28 +19,152 @@ module MatViews
19
19
  #
20
20
  # @example Subclassing BaseService
21
21
  # class MyService < MatViews::Services::BaseService
22
- # def run
23
- # return err("missing view") unless view_exists?
24
- # # perform work...
25
- # ok(:updated, payload: { view: "#{schema}.#{rel}" })
26
- # rescue => e
27
- # error_response(e, meta: { op: "my_service" })
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 MatViews::ServiceResponse
28
33
  # end
29
34
  # end
30
35
  #
31
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
+
32
49
  ##
33
- # @return [MatViews::MatViewDefinition] The target materialized view definition.
50
+ # @return [MatViews::MatViewDefinition] The target materialised view definition.
34
51
  attr_reader :definition
35
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
+
36
74
  ##
37
75
  # @param definition [MatViews::MatViewDefinition]
38
- def initialize(definition)
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)
39
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 [MatViews::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)
40
105
  end
41
106
 
42
107
  private
43
108
 
109
+ ##
110
+ # Core run logic without transaction wrapper.
111
+ # Called by {#call}.
112
+ #
113
+ # @api private
114
+ # @return [MatViews::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 [MatViews::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
+
44
168
  # ────────────────────────────────────────────────────────────────
45
169
  # Schema / resolution helpers
46
170
  # ────────────────────────────────────────────────────────────────
@@ -56,9 +180,9 @@ module MatViews
56
180
  #
57
181
  def first_existing_schema
58
182
  raw_path = conn.schema_search_path.presence || 'public'
59
- candidates = raw_path.split(',').filter_map { |t| resolve_schema_token(t.strip) }
183
+ candidates = raw_path.split(',').filter_map { |token| resolve_schema_token(token.strip) }
60
184
  candidates << 'public' unless candidates.include?('public')
61
- candidates.find { |s| schema_exists?(s) } || 'public'
185
+ candidates.find { |schema_str| schema_exists?(schema_str) } || 'public'
62
186
  end
63
187
 
64
188
  ##
@@ -100,7 +224,7 @@ module MatViews
100
224
  # ────────────────────────────────────────────────────────────────
101
225
 
102
226
  ##
103
- # Whether the materialized view exists for the resolved `schema` and `rel`.
227
+ # Whether the materialised view exists for the resolved `schema` and `rel`.
104
228
  #
105
229
  # @api private
106
230
  # @return [Boolean]
@@ -125,7 +249,7 @@ module MatViews
125
249
  end
126
250
 
127
251
  ##
128
- # Drop the materialized view if it exists (idempotent).
252
+ # Drop the materialised view if it exists (idempotent).
129
253
  #
130
254
  # @api private
131
255
  # @return [void]
@@ -204,42 +328,34 @@ module MatViews
204
328
  # Build a success response.
205
329
  #
206
330
  # @api private
207
- # @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:noop`
208
- # @param payload [Hash] optional payload
209
- # @param meta [Hash] optional metadata
331
+ # @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:skipped`, `:deleted`
210
332
  # @return [MatViews::ServiceResponse]
211
333
  #
212
- def ok(status, payload: {}, meta: {})
213
- MatViews::ServiceResponse.new(status: status, payload: payload, meta: meta)
334
+ def ok(status)
335
+ MatViews::ServiceResponse.new(status:, request:, response:)
214
336
  end
215
337
 
216
338
  ##
217
- # Build an error response with a message.
339
+ # Raise a StandardError with the given message.
218
340
  #
219
341
  # @api private
220
342
  # @param msg [String]
221
- # @return [MatViews::ServiceResponse]
343
+ # @return [void]
344
+ # @raise [StandardError] with `msg`
222
345
  #
223
- def err(msg)
224
- MatViews::ServiceResponse.new(status: :error, error: msg)
346
+ def raise_err(msg)
347
+ raise StandardError, msg
225
348
  end
226
349
 
227
350
  ##
228
351
  # Build an error response from an exception, including backtrace.
229
352
  #
230
353
  # @api private
231
- # @param exception [Exception]
232
- # @param payload [Hash]
233
- # @param meta [Hash]
354
+ # @param error [Exception]
234
355
  # @return [MatViews::ServiceResponse]
235
356
  #
236
- def error_response(exception, payload: {}, meta: {})
237
- MatViews::ServiceResponse.new(
238
- status: :error,
239
- error: "#{exception.class}: #{exception.message}",
240
- payload: payload,
241
- meta: { backtrace: Array(exception.backtrace), **meta }
242
- )
357
+ def error_response(error)
358
+ MatViews::ServiceResponse.new(status: :error, error:, request:, response:)
243
359
  end
244
360
 
245
361
  # ────────────────────────────────────────────────────────────────
@@ -278,30 +394,77 @@ module MatViews
278
394
  #
279
395
  def pg_idle?
280
396
  rc = conn.raw_connection
281
- status = rc.respond_to?(:transaction_status) ? rc.transaction_status : nil
397
+ return true unless rc.respond_to?(:transaction_status)
398
+
282
399
  # Only use CONCURRENTLY outside any tx/savepoint.
283
- status.nil? || status == PG::PQTRANS_IDLE
400
+ rc.transaction_status == PG::PQTRANS_IDLE
284
401
  rescue StandardError
285
402
  false
286
403
  end
287
404
 
288
405
  ##
289
- # Validate SQL starts with SELECT.
406
+ # Check for any UNIQUE index on the materialised view, required by CONCURRENTLY.
290
407
  #
291
408
  # @api private
292
409
  # @return [Boolean]
293
410
  #
294
- def valid_sql?
295
- definition.sql.to_s.strip.upcase.start_with?('SELECT')
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
296
421
  end
297
422
 
423
+ # ────────────────────────────────────────────────────────────────
424
+ # rows counting
425
+ # ────────────────────────────────────────────────────────────────
426
+
298
427
  ##
299
- # Validate that the view name is a sane PostgreSQL identifier.
428
+ # Compute row count based on the configured strategy.
300
429
  #
301
430
  # @api private
302
- # @return [Boolean]
303
- def valid_name?
304
- /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(definition.name.to_s)
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
305
468
  end
306
469
  end
307
470
  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 MatViews
9
+ module Services
10
+ # MatViews::Services::CheckMatviewExists
11
+ # --------------------------------------
12
+ # Service object that checks whether the underlying PostgreSQL **materialised view**
13
+ # for a given {MatViews::MatViewDefinition} currently exists.
14
+ #
15
+ # ### Contract
16
+ # - Inherits from {MatViews::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 = MatViews::MatViewDefinition.find(1)
28
+ # res = MatViews::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 MatViews::Services::BaseService
36
+ # @see MatViews::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
@@ -13,41 +13,33 @@ module MatViews
13
13
  # `REFRESH MATERIALIZED VIEW CONCURRENTLY <schema>.<rel>`
14
14
  #
15
15
  # It keeps the view readable during refresh, but **requires at least one
16
- # UNIQUE index** on the materialized view (a PostgreSQL constraint).
17
- # Returns a {MatViews::ServiceResponse}.
16
+ # UNIQUE index** on the materialised view (a PostgreSQL constraint).
18
17
  #
19
- # Row-count reporting is optional and controlled by `row_count_strategy`:
20
- # - `:estimated` fast/approx via `pg_class.reltuples`
21
- # - `:exact` — accurate `COUNT(*)`
22
- # - `nil` (or any unrecognized value) — skip counting
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 {MatViews::ServiceResponse}
23
22
  #
24
23
  # @see MatViews::Services::RegularRefresh
25
24
  # @see MatViews::Services::SwapRefresh
26
25
  #
27
26
  # @example Direct usage
28
- # svc = MatViews::Services::ConcurrentRefresh.new(definition, row_count_strategy: :exact)
29
- # response = svc.run
27
+ # svc = MatViews::Services::ConcurrentRefresh.new(definition, **options)
28
+ # response = svc.call
30
29
  # response.success? # => true/false
31
30
  #
32
- # @example Via job selection (within RefreshViewJob)
33
- # # When definition.refresh_strategy == "concurrent"
34
- # MatViews::RefreshViewJob.perform_later(definition.id, :estimated)
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
+ # MatViews::Jobs::Adapter.enqueue(MatViews::Services::RefreshViewJob, definition.id, **options)
35
34
  #
36
35
  class ConcurrentRefresh < BaseService
37
- ##
38
- # Strategy for computing row count after refresh.
39
- #
40
- # @return [Symbol, nil] one of `:estimated`, `:exact`, or `nil`
41
- attr_reader :row_count_strategy
42
-
43
- ##
44
- # @param definition [MatViews::MatViewDefinition]
45
- # @param row_count_strategy [Symbol, nil] `:estimated` (default), `:exact`, or `nil`
46
36
  def initialize(definition, row_count_strategy: :estimated)
47
- super(definition)
48
- @row_count_strategy = row_count_strategy
37
+ super
38
+ @use_transaction = false
49
39
  end
50
40
 
41
+ private
42
+
51
43
  ##
52
44
  # Execute the concurrent refresh.
53
45
  #
@@ -55,122 +47,47 @@ module MatViews
55
47
  # If validation fails, returns an error {MatViews::ServiceResponse}.
56
48
  #
57
49
  # @return [MatViews::ServiceResponse]
58
- # - `status: :updated` with payload `{ view:, row_count? }` on success
59
- # - `status: :error` with `error` on failure
60
- #
61
- # @raise [StandardError] bubbled after being wrapped into {#error_response}
62
- #
63
- def run
64
- prep = prepare!
65
- return prep if prep
66
-
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
67
57
  sql = "REFRESH MATERIALIZED VIEW CONCURRENTLY #{qualified_rel}"
58
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
68
59
 
60
+ response[:row_count_before] = fetch_rows_count
69
61
  conn.execute(sql)
62
+ response[:row_count_after] = fetch_rows_count
70
63
 
71
- payload = { view: "#{schema}.#{rel}" }
72
- payload[:row_count] = fetch_rows_count if row_count_strategy.present?
73
-
74
- ok(:updated,
75
- payload: payload,
76
- meta: { sql: sql, row_count_strategy: row_count_strategy, concurrent: true })
77
- rescue PG::ObjectInUse => e
78
- # Common lock/contention error during concurrent refreshes.
79
- error_response(e,
80
- meta: { sql: sql, row_count_strategy: row_count_strategy, concurrent: true },
81
- payload: { view: "#{schema}.#{rel}" })
82
- rescue StandardError => e
83
- error_response(e,
84
- meta: { sql: sql, backtrace: Array(e.backtrace), row_count_strategy: row_count_strategy, concurrent: true },
85
- payload: { view: "#{schema}.#{rel}" })
86
- end
87
-
88
- private
89
-
90
- # ────────────────────────────────────────────────────────────────
91
- # internal
92
- # ────────────────────────────────────────────────────────────────
93
-
94
- ##
95
- # Perform pre-flight checks.
96
- #
97
- # @api private
98
- # @return [MatViews::ServiceResponse, nil] error response or `nil` if OK
99
- #
100
- def prepare!
101
- return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
102
- return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
103
- return err("Materialized view #{schema}.#{rel} must have a unique index for concurrent refresh") unless unique_index_exists?
104
-
105
- nil
106
- end
107
-
108
- # ────────────────────────────────────────────────────────────────
109
- # helpers: validation / schema / pg introspection
110
- # (mirrors RegularRefresh for consistency)
111
- # ────────────────────────────────────────────────────────────────
112
-
113
- ##
114
- # Check for any UNIQUE index on the materialized view, required by CONCURRENTLY.
115
- #
116
- # @api private
117
- # @return [Boolean]
118
- #
119
- def unique_index_exists?
120
- conn.select_value(<<~SQL).to_i.positive?
121
- SELECT COUNT(*)
122
- FROM pg_index i
123
- JOIN pg_class c ON c.oid = i.indrelid
124
- JOIN pg_namespace n ON n.oid = c.relnamespace
125
- WHERE n.nspname = #{conn.quote(schema)}
126
- AND c.relname = #{conn.quote(rel)}
127
- AND i.indisunique = TRUE
128
- SQL
64
+ ok(:updated)
129
65
  end
130
66
 
131
- # ────────────────────────────────────────────────────────────────
132
- # rows counting (same as RegularRefresh)
133
- # ────────────────────────────────────────────────────────────────
134
-
135
67
  ##
136
- # Compute row count based on the configured strategy.
68
+ # Assign the request parameters.
69
+ # Called by {#call} before {#prepare}.
70
+ # Sets `concurrent: true` in the request hash.
137
71
  #
138
72
  # @api private
139
- # @return [Integer, nil]
73
+ # @return [void]
140
74
  #
141
- def fetch_rows_count
142
- case row_count_strategy
143
- when :estimated then estimated_rows_count
144
- when :exact then exact_rows_count
145
- end
75
+ def assign_request
76
+ self.request = { row_count_strategy: row_count_strategy, concurrent: true }
146
77
  end
147
78
 
148
79
  ##
149
- # Fast, approximate row count via `pg_class.reltuples`.
80
+ # Validation step (invoked by BaseService#call before execution).
150
81
  #
151
82
  # @api private
152
- # @return [Integer]
83
+ # @return [nil] on success
84
+ # @raise [StandardError] on failure
153
85
  #
154
- def estimated_rows_count
155
- conn.select_value(<<~SQL).to_i
156
- SELECT COALESCE(c.reltuples::bigint, 0)
157
- FROM pg_class c
158
- JOIN pg_namespace n ON n.oid = c.relnamespace
159
- WHERE c.relkind IN ('m','r','p')
160
- AND n.nspname = #{conn.quote(schema)}
161
- AND c.relname = #{conn.quote(rel)}
162
- LIMIT 1
163
- SQL
164
- end
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?
165
89
 
166
- ##
167
- # Accurate row count using `COUNT(*)` on the materialized view.
168
- #
169
- # @api private
170
- # @return [Integer]
171
- #
172
- def exact_rows_count
173
- conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
90
+ nil
174
91
  end
175
92
  end
176
93
  end