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,25 +8,32 @@
8
8
  module MatViews
9
9
  module Services
10
10
  ##
11
- # Service responsible for creating PostgreSQL materialized views.
11
+ # Service responsible for creating PostgreSQL materialised views.
12
12
  #
13
13
  # The service validates the view definition, handles existence checks,
14
14
  # executes `CREATE MATERIALIZED VIEW ... WITH DATA`, and, when the
15
15
  # refresh strategy is `:concurrent`, ensures a supporting UNIQUE index.
16
16
  #
17
- # Returns a {MatViews::ServiceResponse}.
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 {MatViews::ServiceResponse}
18
22
  #
19
23
  # @see MatViews::Services::RegularRefresh
20
24
  # @see MatViews::Services::ConcurrentRefresh
21
25
  #
22
26
  # @example Create a new matview (no force)
23
- # svc = MatViews::Services::CreateView.new(defn)
24
- # response = svc.run
25
- # response.status # => :created or :noop
27
+ # svc = MatViews::Services::CreateView.new(defn, **options)
28
+ # response = svc.call
29
+ # response.status # => :created or :skipped
26
30
  #
27
31
  # @example Force recreate an existing matview
28
32
  # svc = MatViews::Services::CreateView.new(defn, force: true)
29
- # svc.run
33
+ # svc.call
34
+ #
35
+ # @example via job, this is the typical usage and will create a run record in the DB
36
+ # MatViews::Jobs::Adapter.enqueue(MatViews::Services::CreateViewJob, definition.id, **options)
30
37
  #
31
38
  class CreateView < BaseService
32
39
  ##
@@ -38,91 +45,101 @@ module MatViews
38
45
  ##
39
46
  # @param definition [MatViews::MatViewDefinition]
40
47
  # @param force [Boolean] Whether to drop+recreate an existing matview.
41
- def initialize(definition, force: false)
42
- super(definition)
43
- @force = !!force
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?
44
61
  end
45
62
 
63
+ private
64
+
46
65
  ##
47
66
  # Execute the create operation.
48
67
  #
49
68
  # - Validates name, SQL, and concurrent-index requirements.
50
- # - Handles existing view: noop (default) or drop+recreate (`force: true`).
51
- # - Creates the materialized view WITH DATA.
69
+ # - Handles existing view: skipped (default) or drop+recreate (`force: true`).
70
+ # - Creates the materialised view WITH DATA.
52
71
  # - Creates a UNIQUE index if refresh strategy is concurrent.
53
72
  #
54
- # @return [MatViews::ServiceResponse]
55
- # - `:created` on success (payload includes `view` and `created_indexes`)
56
- # - `:noop` if the view already exists and `force: false`
57
- # - `:error` if validation or execution fails
73
+ # @api private
58
74
  #
59
- def run
60
- prep = prepare!
61
- return prep if prep # error response
62
-
63
- # If exists, either noop or drop+recreate
64
- existed = handle_existing!
75
+ # @return [MatViews::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
65
87
  return existed if existed.is_a?(MatViews::ServiceResponse)
66
88
 
67
- # Always create WITH DATA for a fresh view
68
- create_with_data
89
+ response[:row_count_before] = UNKNOWN_ROW_COUNT
90
+ conn.execute(sql)
91
+ response[:row_count_after] = fetch_rows_count
69
92
 
70
93
  # For concurrent strategy, ensure the unique index so future
71
94
  # REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
72
- index_info = ensure_unique_index_if_needed
73
-
74
- ok(:created, payload: { view: qualified_rel, **index_info })
75
- rescue StandardError => e
76
- error_response(
77
- e,
78
- payload: { view: qualified_rel },
79
- meta: { sql: sql, force: force }
80
- )
81
- end
82
-
83
- private
95
+ response.merge!(ensure_unique_index_if_needed)
84
96
 
85
- # ────────────────────────────────────────────────────────────────
86
- # internal
87
- # ────────────────────────────────────────────────────────────────
97
+ ok(:created)
98
+ end
88
99
 
89
100
  ##
90
- # Validate name, SQL, and concurrent strategy requirements.
101
+ # Validation step (invoked by BaseService#call before execution).
102
+ # Empty for this service as no other preparation is needed.
91
103
  #
92
104
  # @api private
93
- # @return [MatViews::ServiceResponse, nil] error response or nil if OK
94
105
  #
95
- def prepare!
96
- return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
97
- return err('SQL must start with SELECT') unless valid_sql?
98
- return err('refresh_strategy=concurrent requires unique_index_columns (non-empty)') if strategy == 'concurrent' && cols.empty?
106
+ # @return [void]
107
+ #
108
+ def prepare; end
99
109
 
100
- nil
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: }
101
119
  end
102
120
 
103
121
  ##
104
- # Handle existing matview: return noop if not forcing, or drop if forcing.
122
+ # Handle existing matview: return skipped if not forcing, or drop if forcing.
105
123
  #
106
124
  # @api private
107
125
  # @return [MatViews::ServiceResponse, nil]
108
126
  #
109
- def handle_existing!
127
+ def handle_existing
110
128
  return nil unless view_exists?
111
129
 
112
- return MatViews::ServiceResponse.new(status: :noop) unless force
130
+ return ok(:skipped) unless force
113
131
 
114
132
  drop_view
115
133
  nil
116
134
  end
117
135
 
118
136
  ##
119
- # Execute the CREATE MATERIALIZED VIEW WITH DATA statement.
120
- #
137
+ # SQL for `CREATE MATERIALIZED VIEW ... WITH DATA`.
121
138
  # @api private
122
- # @return [void]
139
+ # @return [String]
123
140
  #
124
- def create_with_data
125
- conn.execute(<<~SQL)
141
+ def create_with_data_sql
142
+ <<~SQL
126
143
  CREATE MATERIALIZED VIEW #{qualified_rel} AS
127
144
  #{sql}
128
145
  WITH DATA
@@ -147,9 +164,9 @@ module MatViews
147
164
  concurrently = pg_idle?
148
165
  conn.execute(<<~SQL)
149
166
  CREATE UNIQUE INDEX #{'CONCURRENTLY ' if concurrently}#{quote_table_name(idx_name)}
150
- ON #{qualified_rel} (#{cols.map { |c| quote_column_name(c) }.join(', ')})
167
+ ON #{qualified_rel} (#{cols.map { |col| quote_column_name(col) }.join(', ')})
151
168
  SQL
152
- { created_indexes: [idx_name] }
169
+ { created_indexes: [idx_name], row_count_before: UNKNOWN_ROW_COUNT, row_count_after: fetch_rows_count }
153
170
  end
154
171
  end
155
172
  end
@@ -8,26 +8,26 @@
8
8
  module MatViews
9
9
  module Services
10
10
  ##
11
- # Service that safely drops a PostgreSQL materialized view.
11
+ # Service that safely drops a PostgreSQL materialised view.
12
12
  #
13
13
  # Options:
14
- # - `if_exists:` (Boolean, default: true) → idempotent drop (skip if absent)
15
- # - `cascade:` (Boolean, default: false) → use CASCADE instead of RESTRICT
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
16
  #
17
- # Returns a {MatViews::ServiceResponse} from {MatViews::Services::BaseService}:
18
- # - `ok(:deleted, ...)` when dropped successfully
19
- # - `ok(:skipped, ...)` when absent and `if_exists: true`
20
- # - `err("...")` or `error_response(...)` on validation or execution error
17
+ # Returns a {MatViews::ServiceResponse}
21
18
  #
22
19
  # @see MatViews::DeleteViewJob
23
20
  # @see MatViews::MatViewRun
24
21
  #
25
22
  # @example Drop a view if it exists
26
- # svc = MatViews::Services::DeleteView.new(defn)
27
- # svc.run
23
+ # svc = MatViews::Services::DeleteView.new(defn, **options)
24
+ # svc.call
28
25
  #
29
26
  # @example Force drop with CASCADE
30
- # MatViews::Services::DeleteView.new(defn, cascade: true).run
27
+ # MatViews::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
+ # MatViews::Jobs::Adapter.enqueue(MatViews::Services::DeleteViewJob, definition.id, **options)
31
31
  #
32
32
  class DeleteView < BaseService
33
33
  ##
@@ -36,122 +36,73 @@ module MatViews
36
36
  # @return [Boolean]
37
37
  attr_reader :cascade
38
38
 
39
- ##
40
- # Whether to allow idempotent skipping if view is absent (default: true).
41
- #
42
- # @return [Boolean]
43
- attr_reader :if_exists
44
-
45
39
  ##
46
40
  # @param definition [MatViews::MatViewDefinition]
47
41
  # @param cascade [Boolean] drop with CASCADE instead of RESTRICT
48
- # @param if_exists [Boolean] skip if view not present
49
- def initialize(definition, cascade: false, if_exists: true)
50
- super(definition)
51
- @cascade = cascade ? true : false
52
- @if_exists = if_exists ? true : false
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
53
46
  end
54
47
 
48
+ private
49
+
55
50
  ##
56
51
  # Run the drop operation.
57
52
  #
58
53
  # Steps:
59
- # - Validate name format and (optionally) existence.
60
- # - Return `:skipped` if absent and `if_exists` true.
54
+ # - Validate name format
55
+ # - return :skipped if absent
61
56
  # - Execute DROP MATERIALIZED VIEW.
62
57
  #
63
- # @return [MatViews::ServiceResponse]
64
- #
65
- def run
66
- prep = prepare!
67
- return prep if prep
68
-
69
- res = skip_early_if_absent
70
- return res if res
71
-
72
- perform_drop
73
- end
74
-
75
- private
76
-
77
- # ────────────────────────────────────────────────────────────────
78
- # internal
79
- # ────────────────────────────────────────────────────────────────
80
-
81
- ##
82
- # Execute the DROP MATERIALIZED VIEW statement.
83
- #
84
- # @api private
85
- # @return [MatViews::ServiceResponse]
86
- #
87
- def perform_drop
88
- conn.execute(sql)
89
-
90
- ok(:deleted,
91
- payload: { view: "#{schema}.#{rel}" },
92
- meta: { sql: sql, cascade: cascade, if_exists: if_exists })
93
- rescue ActiveRecord::StatementInvalid => e
94
- msg = "#{e.message} — dependencies exist. Use cascade: true to force drop."
95
- error_response(
96
- e.class.new(msg),
97
- meta: { sql: sql, cascade: cascade, if_exists: if_exists },
98
- payload: { view: "#{schema}.#{rel}" }
99
- )
100
- rescue StandardError => e
101
- error_response(
102
- e,
103
- meta: { sql: sql, cascade: cascade, if_exists: if_exists },
104
- payload: { view: "#{schema}.#{rel}" }
105
- )
106
- end
107
-
108
- ##
109
- # Skip early if view is absent and `if_exists` is true.
110
- #
111
58
  # @api private
112
- # @return [MatViews::ServiceResponse, nil]
113
59
  #
114
- def skip_early_if_absent
115
- return nil unless if_exists
116
- return nil if view_exists?
117
-
118
- ok(:skipped,
119
- payload: { view: "#{schema}.#{rel}" },
120
- meta: { sql: nil, cascade: cascade, if_exists: if_exists })
60
+ # @return [MatViews::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)
121
76
  end
122
77
 
123
78
  ##
124
- # Build the SQL DROP statement.
79
+ # Assign the request parameters.
80
+ # Called by {#call} before {#prepare}.
81
+ # Sets `concurrent: true` in the request hash.
125
82
  #
126
83
  # @api private
127
- # @return [String]
84
+ # @return [void]
128
85
  #
129
- def sql
130
- @sql ||= build_sql
86
+ def assign_request
87
+ self.request = { row_count_strategy: row_count_strategy, cascade: cascade }
131
88
  end
132
89
 
133
90
  ##
134
- # Validate name and existence depending on options.
91
+ # Validation step (invoked by BaseService#call before execution).
92
+ # Empty for this service as no other preparation is needed.
135
93
  #
136
94
  # @api private
137
- # @return [MatViews::ServiceResponse, nil]
138
95
  #
139
- def prepare!
140
- return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
141
- return nil if if_exists # skip hard existence check
142
-
143
- return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
144
-
145
- nil
146
- end
96
+ # @return [void]
97
+ def prepare; end
147
98
 
148
99
  ##
149
- # Construct DROP SQL with cascade/restrict options.
100
+ # Build the SQL DROP statement.
150
101
  #
151
102
  # @api private
152
103
  # @return [String]
153
104
  #
154
- def build_sql
105
+ def drop_sql
155
106
  drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
156
107
  %(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
157
108
  end
@@ -13,32 +13,25 @@ module MatViews
13
13
  # This is the safest option for simple or low-frequency updates where
14
14
  # blocking reads during refresh is acceptable.
15
15
  #
16
- # Supports optional row counting strategies:
17
- # - `:estimated` → uses `pg_class.reltuples` (fast, approximate)
18
- # - `:exact` → runs `COUNT(*)` (accurate, but potentially slow)
19
- # - `nil` → no row count included in payload
16
+ # Options:
17
+ # - `row_count_strategy:` (Symbol, default: :none) one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
20
18
  #
21
- # @return [MatViews::ServiceResponse]
19
+ # Returns a {MatViews::ServiceResponse}
22
20
  #
23
- # @example
24
- # svc = MatViews::Services::RegularRefresh.new(defn)
25
- # svc.run
21
+ # @see MatViews::Services::ConcurrentRefresh
22
+ # @see MatViews::Services::SwapRefresh
23
+ #
24
+ # @example Direct usage
25
+ # svc = MatViews::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
+ # MatViews::Jobs::Adapter.enqueue(MatViews::Services::RegularRefresh, definition.id, **options)
26
32
  #
27
33
  class RegularRefresh < BaseService
28
- ##
29
- # The row count strategy requested.
30
- # One of `:estimated`, `:exact`, `nil`, or unrecognized symbol.
31
- #
32
- # @return [Symbol, nil]
33
- attr_reader :row_count_strategy
34
-
35
- ##
36
- # @param definition [MatViews::MatViewDefinition]
37
- # @param row_count_strategy [Symbol, nil] row counting mode
38
- def initialize(definition, row_count_strategy: :estimated)
39
- super(definition)
40
- @row_count_strategy = row_count_strategy
41
- end
34
+ private
42
35
 
43
36
  ##
44
37
  # Perform the refresh.
@@ -49,97 +42,48 @@ module MatViews
49
42
  # - Optionally compute row count.
50
43
  #
51
44
  # @return [MatViews::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
52
51
  #
53
- def run
54
- prep = prepare!
55
- return prep if prep
56
-
52
+ def _run
57
53
  sql = "REFRESH MATERIALIZED VIEW #{qualified_rel}"
58
54
 
59
- conn.execute(sql)
55
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
60
56
 
61
- payload = { view: "#{schema}.#{rel}" }
62
- payload[:row_count] = fetch_rows_count if row_count_strategy.present?
57
+ response[:row_count_before] = fetch_rows_count
58
+ conn.execute(sql)
59
+ response[:row_count_after] = fetch_rows_count
63
60
 
64
- ok(:updated,
65
- payload: payload,
66
- meta: { sql: sql, row_count_strategy: row_count_strategy })
67
- rescue StandardError => e
68
- error_response(
69
- e,
70
- meta: {
71
- sql: sql,
72
- backtrace: Array(e.backtrace),
73
- row_count_strategy: row_count_strategy
74
- },
75
- payload: { view: "#{schema}.#{rel}" }
76
- )
61
+ ok(:updated)
77
62
  end
78
63
 
79
- private
80
-
81
- # ────────────────────────────────────────────────────────────────
82
- # internal
83
- # ────────────────────────────────────────────────────────────────
84
-
85
64
  ##
86
- # Validate name and existence of the materialized view.
65
+ # Validation step (invoked by BaseService#call before execution).
66
+ # Ensures view exists.
87
67
  #
88
68
  # @api private
89
- # @return [MatViews::ServiceResponse, nil]
90
69
  #
91
- def prepare!
92
- return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
93
- return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
94
-
95
- nil
96
- end
97
-
98
- # ────────────────────────────────────────────────────────────────
99
- # rows counting
100
- # ────────────────────────────────────────────────────────────────
101
-
102
- ##
103
- # Pick the appropriate row count method.
104
- #
105
- # @api private
106
- # @return [Integer, nil]
70
+ # @return [void]
107
71
  #
108
- def fetch_rows_count
109
- case row_count_strategy
110
- when :estimated then estimated_rows_count
111
- when :exact then exact_rows_count
112
- end
113
- end
72
+ def prepare
73
+ raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
114
74
 
115
- ##
116
- # Fast/approx via `pg_class.reltuples`.
117
- # Updated by `ANALYZE` and autovacuum.
118
- #
119
- # @api private
120
- # @return [Integer]
121
- #
122
- def estimated_rows_count
123
- conn.select_value(<<~SQL).to_i
124
- SELECT COALESCE(c.reltuples::bigint, 0)
125
- FROM pg_class c
126
- JOIN pg_namespace n ON n.oid = c.relnamespace
127
- WHERE c.relkind IN ('m','r','p')
128
- AND n.nspname = #{conn.quote(schema)}
129
- AND c.relname = #{conn.quote(rel)}
130
- LIMIT 1
131
- SQL
75
+ nil
132
76
  end
133
77
 
134
78
  ##
135
- # Accurate count via `COUNT(*)`.
136
- # Potentially slow on large materialized views.
79
+ # Assign the request parameters.
80
+ # Called by {#call} before {#prepare}.
137
81
  #
138
82
  # @api private
139
- # @return [Integer]
83
+ # @return [void]
140
84
  #
141
- def exact_rows_count
142
- conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
85
+ def assign_request
86
+ self.request = { row_count_strategy: row_count_strategy }
143
87
  end
144
88
  end
145
89
  end