mat_views 0.1.2

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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +121 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/stylesheets/mat_views/application.css +15 -0
  6. data/app/jobs/mat_views/application_job.rb +39 -0
  7. data/app/jobs/mat_views/create_view_job.rb +188 -0
  8. data/app/jobs/mat_views/delete_view_job.rb +196 -0
  9. data/app/jobs/mat_views/refresh_view_job.rb +215 -0
  10. data/app/models/mat_views/application_record.rb +34 -0
  11. data/app/models/mat_views/mat_view_definition.rb +98 -0
  12. data/app/models/mat_views/mat_view_run.rb +89 -0
  13. data/config/routes.rb +12 -0
  14. data/lib/generators/mat_views/install/install_generator.rb +86 -0
  15. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +29 -0
  16. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +32 -0
  17. data/lib/generators/mat_views/install/templates/mat_views_initializer.rb +23 -0
  18. data/lib/mat_views/configuration.rb +49 -0
  19. data/lib/mat_views/engine.rb +34 -0
  20. data/lib/mat_views/jobs/adapter.rb +78 -0
  21. data/lib/mat_views/service_response.rb +60 -0
  22. data/lib/mat_views/services/base_service.rb +308 -0
  23. data/lib/mat_views/services/concurrent_refresh.rb +177 -0
  24. data/lib/mat_views/services/create_view.rb +156 -0
  25. data/lib/mat_views/services/delete_view.rb +160 -0
  26. data/lib/mat_views/services/regular_refresh.rb +146 -0
  27. data/lib/mat_views/services/swap_refresh.rb +221 -0
  28. data/lib/mat_views/version.rb +21 -0
  29. data/lib/mat_views.rb +57 -0
  30. data/lib/tasks/helpers.rb +185 -0
  31. data/lib/tasks/mat_views_tasks.rake +145 -0
  32. metadata +95 -0
@@ -0,0 +1,34 @@
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
+ ##
10
+ # Rails Engine for MatViews.
11
+ #
12
+ # This engine encapsulates all functionality related to
13
+ # materialized views, including:
14
+ # - Defining materialized view definitions
15
+ # - Creating and refreshing views
16
+ # - Managing background jobs for refresh/create/delete
17
+ #
18
+ # By isolating the namespace, it ensures that routes, models,
19
+ # and helpers do not conflict with the host application.
20
+ #
21
+ # @example Mounting the engine in a Rails application
22
+ # # config/routes.rb
23
+ # Rails.application.routes.draw do
24
+ # mount MatViews::Engine => "/mat_views"
25
+ # end
26
+ #
27
+ class Engine < ::Rails::Engine
28
+ isolate_namespace MatViews
29
+
30
+ initializer 'mat_views.load_config' do
31
+ MatViews.configuration ||= MatViews::Configuration.new
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,78 @@
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
+ ##
9
+ # Top-level namespace for the mat_views engine.
10
+ module MatViews
11
+ ##
12
+ # Namespace for job-related utilities and integrations.
13
+ module Jobs
14
+ ##
15
+ # Adapter class for handling job enqueuing across different backends.
16
+ #
17
+ # This class abstracts the job enqueueing process so MatViews can work
18
+ # with multiple background processing frameworks without changing core code.
19
+ #
20
+ # Supported adapters (configured via `MatViews.configuration.job_adapter`):
21
+ # - `:active_job` → {ActiveJob}
22
+ # - `:sidekiq` → {Sidekiq::Client}
23
+ # - `:resque` → {Resque}
24
+ #
25
+ # @example Enqueue via ActiveJob
26
+ # MatViews.configuration.job_adapter = :active_job
27
+ # MatViews::Jobs::Adapter.enqueue(MyJob, queue: :low, args: [1, "foo"])
28
+ #
29
+ # @example Enqueue via Sidekiq
30
+ # MatViews.configuration.job_adapter = :sidekiq
31
+ # MatViews::Jobs::Adapter.enqueue(MyWorker, queue: :critical, args: [42])
32
+ #
33
+ # @example Enqueue via Resque
34
+ # MatViews.configuration.job_adapter = :resque
35
+ # MatViews::Jobs::Adapter.enqueue(MyWorker, queue: :default, args: %w[a b c])
36
+ #
37
+ # @raise [ArgumentError] if the configured adapter is not recognized
38
+ #
39
+ class Adapter
40
+ ##
41
+ # Enqueue a job across supported backends.
42
+ #
43
+ # @api public
44
+ #
45
+ # @param job_class [Class] The job or worker class to enqueue.
46
+ # - For `:active_job`, this should be a subclass of {ActiveJob::Base}.
47
+ # - For `:sidekiq`, this should be a Sidekiq worker class.
48
+ # - For `:resque`, this should be a Resque worker class.
49
+ #
50
+ # @param queue [String, Symbol] Target queue name.
51
+ # @param args [Array] Arguments to pass into the job/worker.
52
+ #
53
+ # @return [Object] Framework-dependent:
54
+ # - For ActiveJob → enqueued {ActiveJob::Base} instance
55
+ # - For Sidekiq → job ID hash
56
+ # - For Resque → `true` if enqueue succeeded
57
+ #
58
+ # @raise [ArgumentError] if the configured adapter is not recognized.
59
+ #
60
+ def self.enqueue(job_class, queue:, args: [])
61
+ case MatViews.configuration.job_adapter
62
+ when :active_job
63
+ job_class.set(queue: queue.to_s).perform_later(*args)
64
+ when :sidekiq
65
+ Sidekiq::Client.push(
66
+ 'class' => job_class.name,
67
+ 'queue' => queue.to_s,
68
+ 'args' => args
69
+ )
70
+ when :resque
71
+ Resque.enqueue_to(queue.to_s, job_class, *args)
72
+ else
73
+ raise ArgumentError, "Unknown job adapter: #{MatViews.configuration.job_adapter.inspect}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,60 @@
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
+ ##
10
+ # Encapsulates the result of a service operation within MatViews.
11
+ #
12
+ # Provides a consistent contract for all services by standardizing:
13
+ # - `status`: Symbol representing outcome (`:ok`, `:created`, `:updated`, `:noop`,
14
+ # `:skipped`, `:deleted`, `:error`)
15
+ # - `payload`: Arbitrary structured data returned by the service
16
+ # - `error`: Exception object or message if an error occurred
17
+ # - `meta`: Additional metadata such as SQL statements, timing, or strategies
18
+ #
19
+ # @example Successful response
20
+ # MatViews::ServiceResponse.new(
21
+ # status: :updated,
22
+ # payload: { view: "public.users_mv" }
23
+ # )
24
+ #
25
+ # @example Error response
26
+ # MatViews::ServiceResponse.new(
27
+ # status: :error,
28
+ # error: StandardError.new("Something went wrong")
29
+ # )
30
+ #
31
+ class ServiceResponse
32
+ attr_reader :status, :payload, :error, :meta
33
+
34
+ # @param status [Symbol] the outcome status
35
+ # @param payload [Hash] optional data payload
36
+ # @param error [Exception, String, nil] error details if applicable
37
+ # @param meta [Hash] additional metadata
38
+ def initialize(status:, payload: {}, error: nil, meta: {})
39
+ @status = status.to_sym
40
+ @payload = payload
41
+ @error = error
42
+ @meta = meta
43
+ end
44
+
45
+ # @return [Boolean] whether the response represents a success
46
+ def success?
47
+ !error? && %i[ok created updated noop skipped deleted].include?(status)
48
+ end
49
+
50
+ # @return [Boolean] whether the response represents an error
51
+ def error?
52
+ !error.nil? || status == :error
53
+ end
54
+
55
+ # @return [Hash] hash representation of the response
56
+ def to_h
57
+ { status:, payload:, error:, meta: }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,308 @@
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
+ ##
11
+ # Base class for service objects that operate on PostgreSQL materialized
12
+ # views (create/refresh/delete, schema discovery, quoting, and common
13
+ # response helpers).
14
+ #
15
+ # Concrete services (e.g., {MatViews::Services::CreateView},
16
+ # {MatViews::Services::RegularRefresh}) should inherit from this class.
17
+ #
18
+ # @abstract
19
+ #
20
+ # @example Subclassing BaseService
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" })
28
+ # end
29
+ # end
30
+ #
31
+ class BaseService
32
+ ##
33
+ # @return [MatViews::MatViewDefinition] The target materialized view definition.
34
+ attr_reader :definition
35
+
36
+ ##
37
+ # @param definition [MatViews::MatViewDefinition]
38
+ def initialize(definition)
39
+ @definition = definition
40
+ end
41
+
42
+ private
43
+
44
+ # ────────────────────────────────────────────────────────────────
45
+ # Schema / resolution helpers
46
+ # ────────────────────────────────────────────────────────────────
47
+
48
+ ##
49
+ # Resolve the first existing schema from `schema_search_path`,
50
+ # falling back to `"public"` if none are valid.
51
+ #
52
+ # Supports `$user`, quoted tokens, and ignores non-existent schemas.
53
+ #
54
+ # @api private
55
+ # @return [String] a valid schema name
56
+ #
57
+ def first_existing_schema
58
+ raw_path = conn.schema_search_path.presence || 'public'
59
+ candidates = raw_path.split(',').filter_map { |t| resolve_schema_token(t.strip) }
60
+ candidates << 'public' unless candidates.include?('public')
61
+ candidates.find { |s| schema_exists?(s) } || 'public'
62
+ end
63
+
64
+ ##
65
+ # Normalize a schema token:
66
+ # - strip surrounding quotes
67
+ # - expand `$user` to the current database user
68
+ #
69
+ # @api private
70
+ # @param token [String]
71
+ # @return [String]
72
+ #
73
+ def resolve_schema_token(token)
74
+ cleaned = token.delete_prefix('"').delete_suffix('"')
75
+ return current_user if cleaned == '$user'
76
+
77
+ cleaned
78
+ end
79
+
80
+ ##
81
+ # @api private
82
+ # @return [String] current PostgreSQL user
83
+ def current_user
84
+ @current_user ||= conn.select_value('SELECT current_user')
85
+ end
86
+
87
+ ##
88
+ # Check whether a schema exists.
89
+ #
90
+ # @api private
91
+ # @param name [String] schema name
92
+ # @return [Boolean]
93
+ #
94
+ def schema_exists?(name)
95
+ conn.select_value("SELECT to_regnamespace(#{conn.quote(name)}) IS NOT NULL")
96
+ end
97
+
98
+ # ────────────────────────────────────────────────────────────────
99
+ # View / relation helpers
100
+ # ────────────────────────────────────────────────────────────────
101
+
102
+ ##
103
+ # Whether the materialized view exists for the resolved `schema` and `rel`.
104
+ #
105
+ # @api private
106
+ # @return [Boolean]
107
+ #
108
+ def view_exists?
109
+ conn.select_value(<<~SQL).to_i.positive?
110
+ SELECT COUNT(*)
111
+ FROM pg_matviews
112
+ WHERE schemaname = #{conn.quote(schema)}
113
+ AND matviewname = #{conn.quote(rel)}
114
+ SQL
115
+ end
116
+
117
+ ##
118
+ # Fully-qualified, safely-quoted relation name, e.g. `"public"."mv_users"`.
119
+ #
120
+ # @api private
121
+ # @return [String]
122
+ #
123
+ def qualified_rel
124
+ %(#{quote_table_name(schema)}.#{quote_table_name(rel)})
125
+ end
126
+
127
+ ##
128
+ # Drop the materialized view if it exists (idempotent).
129
+ #
130
+ # @api private
131
+ # @return [void]
132
+ #
133
+ def drop_view
134
+ conn.execute(<<~SQL)
135
+ DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}
136
+ SQL
137
+ end
138
+
139
+ ##
140
+ # Refresh strategy from the definition (stringified).
141
+ #
142
+ # @api private
143
+ # @return [String] one of `"regular"`, `"concurrent"`, `"swap"` (or custom)
144
+ #
145
+ def strategy
146
+ @strategy ||= definition.refresh_strategy.to_s
147
+ end
148
+
149
+ ##
150
+ # Unqualified relation (matview) name from the definition.
151
+ #
152
+ # @api private
153
+ # @return [String]
154
+ #
155
+ def rel
156
+ @rel ||= definition.name.to_s
157
+ end
158
+
159
+ ##
160
+ # SQL `SELECT …` for the materialization.
161
+ #
162
+ # @api private
163
+ # @return [String]
164
+ #
165
+ def sql
166
+ @sql ||= definition.sql.to_s
167
+ end
168
+
169
+ ##
170
+ # Unique index column list (normalized to strings, unique).
171
+ #
172
+ # @api private
173
+ # @return [Array<String>]
174
+ #
175
+ def cols
176
+ @cols ||= Array(definition.unique_index_columns).map(&:to_s).uniq
177
+ end
178
+
179
+ ##
180
+ # ActiveRecord connection.
181
+ #
182
+ # @api private
183
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
184
+ #
185
+ def conn
186
+ @conn ||= ActiveRecord::Base.connection
187
+ end
188
+
189
+ ##
190
+ # Resolved, existing schema for this operation.
191
+ #
192
+ # @api private
193
+ # @return [String]
194
+ #
195
+ def schema
196
+ @schema ||= first_existing_schema
197
+ end
198
+
199
+ # ────────────────────────────────────────────────────────────────
200
+ # Response helpers
201
+ # ────────────────────────────────────────────────────────────────
202
+
203
+ ##
204
+ # Build a success response.
205
+ #
206
+ # @api private
207
+ # @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:noop`
208
+ # @param payload [Hash] optional payload
209
+ # @param meta [Hash] optional metadata
210
+ # @return [MatViews::ServiceResponse]
211
+ #
212
+ def ok(status, payload: {}, meta: {})
213
+ MatViews::ServiceResponse.new(status: status, payload: payload, meta: meta)
214
+ end
215
+
216
+ ##
217
+ # Build an error response with a message.
218
+ #
219
+ # @api private
220
+ # @param msg [String]
221
+ # @return [MatViews::ServiceResponse]
222
+ #
223
+ def err(msg)
224
+ MatViews::ServiceResponse.new(status: :error, error: msg)
225
+ end
226
+
227
+ ##
228
+ # Build an error response from an exception, including backtrace.
229
+ #
230
+ # @api private
231
+ # @param exception [Exception]
232
+ # @param payload [Hash]
233
+ # @param meta [Hash]
234
+ # @return [MatViews::ServiceResponse]
235
+ #
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
+ )
243
+ end
244
+
245
+ # ────────────────────────────────────────────────────────────────
246
+ # Quoting / environment helpers
247
+ # ────────────────────────────────────────────────────────────────
248
+
249
+ ##
250
+ # Quote a column name for SQL.
251
+ #
252
+ # @api private
253
+ # @param name [String, Symbol]
254
+ # @return [String] quoted column name
255
+ #
256
+ def quote_column_name(name)
257
+ conn.quote_column_name(name)
258
+ end
259
+
260
+ ##
261
+ # Quote a table/relation name for SQL.
262
+ #
263
+ # @api private
264
+ # @param name [String, Symbol]
265
+ # @return [String] quoted relation name
266
+ #
267
+ def quote_table_name(name)
268
+ conn.quote_table_name(name)
269
+ end
270
+
271
+ ##
272
+ # Whether the underlying PG connection is idle (no active tx/savepoint).
273
+ #
274
+ # Used to guard `CONCURRENTLY` operations which must run outside a txn.
275
+ #
276
+ # @api private
277
+ # @return [Boolean]
278
+ #
279
+ def pg_idle?
280
+ rc = conn.raw_connection
281
+ status = rc.respond_to?(:transaction_status) ? rc.transaction_status : nil
282
+ # Only use CONCURRENTLY outside any tx/savepoint.
283
+ status.nil? || status == PG::PQTRANS_IDLE
284
+ rescue StandardError
285
+ false
286
+ end
287
+
288
+ ##
289
+ # Validate SQL starts with SELECT.
290
+ #
291
+ # @api private
292
+ # @return [Boolean]
293
+ #
294
+ def valid_sql?
295
+ definition.sql.to_s.strip.upcase.start_with?('SELECT')
296
+ end
297
+
298
+ ##
299
+ # Validate that the view name is a sane PostgreSQL identifier.
300
+ #
301
+ # @api private
302
+ # @return [Boolean]
303
+ def valid_name?
304
+ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(definition.name.to_s)
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,177 @@
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
+ ##
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 materialized view (a PostgreSQL constraint).
17
+ # Returns a {MatViews::ServiceResponse}.
18
+ #
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
23
+ #
24
+ # @see MatViews::Services::RegularRefresh
25
+ # @see MatViews::Services::SwapRefresh
26
+ #
27
+ # @example Direct usage
28
+ # svc = MatViews::Services::ConcurrentRefresh.new(definition, row_count_strategy: :exact)
29
+ # response = svc.run
30
+ # response.success? # => true/false
31
+ #
32
+ # @example Via job selection (within RefreshViewJob)
33
+ # # When definition.refresh_strategy == "concurrent"
34
+ # MatViews::RefreshViewJob.perform_later(definition.id, :estimated)
35
+ #
36
+ 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
+ def initialize(definition, row_count_strategy: :estimated)
47
+ super(definition)
48
+ @row_count_strategy = row_count_strategy
49
+ end
50
+
51
+ ##
52
+ # Execute the concurrent refresh.
53
+ #
54
+ # Validates name format, existence of the matview, and presence of a UNIQUE index.
55
+ # If validation fails, returns an error {MatViews::ServiceResponse}.
56
+ #
57
+ # @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
+
67
+ sql = "REFRESH MATERIALIZED VIEW CONCURRENTLY #{qualified_rel}"
68
+
69
+ conn.execute(sql)
70
+
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
129
+ end
130
+
131
+ # ────────────────────────────────────────────────────────────────
132
+ # rows counting (same as RegularRefresh)
133
+ # ────────────────────────────────────────────────────────────────
134
+
135
+ ##
136
+ # Compute row count based on the configured strategy.
137
+ #
138
+ # @api private
139
+ # @return [Integer, nil]
140
+ #
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
146
+ end
147
+
148
+ ##
149
+ # Fast, approximate row count via `pg_class.reltuples`.
150
+ #
151
+ # @api private
152
+ # @return [Integer]
153
+ #
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
165
+
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
174
+ end
175
+ end
176
+ end
177
+ end