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,156 @@
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
+ # Service responsible for creating PostgreSQL materialized views.
12
+ #
13
+ # The service validates the view definition, handles existence checks,
14
+ # executes `CREATE MATERIALIZED VIEW ... WITH DATA`, and, when the
15
+ # refresh strategy is `:concurrent`, ensures a supporting UNIQUE index.
16
+ #
17
+ # Returns a {MatViews::ServiceResponse}.
18
+ #
19
+ # @see MatViews::Services::RegularRefresh
20
+ # @see MatViews::Services::ConcurrentRefresh
21
+ #
22
+ # @example Create a new matview (no force)
23
+ # svc = MatViews::Services::CreateView.new(defn)
24
+ # response = svc.run
25
+ # response.status # => :created or :noop
26
+ #
27
+ # @example Force recreate an existing matview
28
+ # svc = MatViews::Services::CreateView.new(defn, force: true)
29
+ # svc.run
30
+ #
31
+ class CreateView < BaseService
32
+ ##
33
+ # Whether to force recreation (drop+create if exists).
34
+ #
35
+ # @return [Boolean]
36
+ attr_reader :force
37
+
38
+ ##
39
+ # @param definition [MatViews::MatViewDefinition]
40
+ # @param force [Boolean] Whether to drop+recreate an existing matview.
41
+ def initialize(definition, force: false)
42
+ super(definition)
43
+ @force = !!force
44
+ end
45
+
46
+ ##
47
+ # Execute the create operation.
48
+ #
49
+ # - 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.
52
+ # - Creates a UNIQUE index if refresh strategy is concurrent.
53
+ #
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
58
+ #
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!
65
+ return existed if existed.is_a?(MatViews::ServiceResponse)
66
+
67
+ # Always create WITH DATA for a fresh view
68
+ create_with_data
69
+
70
+ # For concurrent strategy, ensure the unique index so future
71
+ # 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
84
+
85
+ # ────────────────────────────────────────────────────────────────
86
+ # internal
87
+ # ────────────────────────────────────────────────────────────────
88
+
89
+ ##
90
+ # Validate name, SQL, and concurrent strategy requirements.
91
+ #
92
+ # @api private
93
+ # @return [MatViews::ServiceResponse, nil] error response or nil if OK
94
+ #
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?
99
+
100
+ nil
101
+ end
102
+
103
+ ##
104
+ # Handle existing matview: return noop if not forcing, or drop if forcing.
105
+ #
106
+ # @api private
107
+ # @return [MatViews::ServiceResponse, nil]
108
+ #
109
+ def handle_existing!
110
+ return nil unless view_exists?
111
+
112
+ return MatViews::ServiceResponse.new(status: :noop) unless force
113
+
114
+ drop_view
115
+ nil
116
+ end
117
+
118
+ ##
119
+ # Execute the CREATE MATERIALIZED VIEW WITH DATA statement.
120
+ #
121
+ # @api private
122
+ # @return [void]
123
+ #
124
+ def create_with_data
125
+ conn.execute(<<~SQL)
126
+ CREATE MATERIALIZED VIEW #{qualified_rel} AS
127
+ #{sql}
128
+ WITH DATA
129
+ SQL
130
+ end
131
+
132
+ ##
133
+ # Ensure a UNIQUE index if refresh strategy is concurrent.
134
+ #
135
+ # Builds an index name like `public_mvname_uniq_col1_col2`.
136
+ # Creates it concurrently if the PG connection is idle.
137
+ #
138
+ # @api private
139
+ # @return [Hash] `{ created_indexes: [String] }` or empty array if not needed
140
+ #
141
+ def ensure_unique_index_if_needed
142
+ return { created_indexes: [] } unless strategy == 'concurrent'
143
+
144
+ # Name like: public_mvname_uniq_col1_col2
145
+ idx_name = [schema, rel, 'uniq', *cols].join('_')
146
+
147
+ concurrently = pg_idle?
148
+ conn.execute(<<~SQL)
149
+ CREATE UNIQUE INDEX #{'CONCURRENTLY ' if concurrently}#{quote_table_name(idx_name)}
150
+ ON #{qualified_rel} (#{cols.map { |c| quote_column_name(c) }.join(', ')})
151
+ SQL
152
+ { created_indexes: [idx_name] }
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,160 @@
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
+ # Service that safely drops a PostgreSQL materialized view.
12
+ #
13
+ # Options:
14
+ # - `if_exists:` (Boolean, default: true) → idempotent drop (skip if absent)
15
+ # - `cascade:` (Boolean, default: false) → use CASCADE instead of RESTRICT
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
21
+ #
22
+ # @see MatViews::DeleteViewJob
23
+ # @see MatViews::MatViewRun
24
+ #
25
+ # @example Drop a view if it exists
26
+ # svc = MatViews::Services::DeleteView.new(defn)
27
+ # svc.run
28
+ #
29
+ # @example Force drop with CASCADE
30
+ # MatViews::Services::DeleteView.new(defn, cascade: true).run
31
+ #
32
+ class DeleteView < BaseService
33
+ ##
34
+ # Whether to cascade the drop (default: false).
35
+ #
36
+ # @return [Boolean]
37
+ attr_reader :cascade
38
+
39
+ ##
40
+ # Whether to allow idempotent skipping if view is absent (default: true).
41
+ #
42
+ # @return [Boolean]
43
+ attr_reader :if_exists
44
+
45
+ ##
46
+ # @param definition [MatViews::MatViewDefinition]
47
+ # @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
53
+ end
54
+
55
+ ##
56
+ # Run the drop operation.
57
+ #
58
+ # Steps:
59
+ # - Validate name format and (optionally) existence.
60
+ # - Return `:skipped` if absent and `if_exists` true.
61
+ # - Execute DROP MATERIALIZED VIEW.
62
+ #
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
+ # @api private
112
+ # @return [MatViews::ServiceResponse, nil]
113
+ #
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 })
121
+ end
122
+
123
+ ##
124
+ # Build the SQL DROP statement.
125
+ #
126
+ # @api private
127
+ # @return [String]
128
+ #
129
+ def sql
130
+ @sql ||= build_sql
131
+ end
132
+
133
+ ##
134
+ # Validate name and existence depending on options.
135
+ #
136
+ # @api private
137
+ # @return [MatViews::ServiceResponse, nil]
138
+ #
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
147
+
148
+ ##
149
+ # Construct DROP SQL with cascade/restrict options.
150
+ #
151
+ # @api private
152
+ # @return [String]
153
+ #
154
+ def build_sql
155
+ drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
156
+ %(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,146 @@
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
+ # Service that executes a standard (locking) `REFRESH MATERIALIZED VIEW`.
12
+ #
13
+ # This is the safest option for simple or low-frequency updates where
14
+ # blocking reads during refresh is acceptable.
15
+ #
16
+ # 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
20
+ #
21
+ # @return [MatViews::ServiceResponse]
22
+ #
23
+ # @example
24
+ # svc = MatViews::Services::RegularRefresh.new(defn)
25
+ # svc.run
26
+ #
27
+ 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
42
+
43
+ ##
44
+ # Perform the refresh.
45
+ #
46
+ # Steps:
47
+ # - Validate name & existence.
48
+ # - Run `REFRESH MATERIALIZED VIEW`.
49
+ # - Optionally compute row count.
50
+ #
51
+ # @return [MatViews::ServiceResponse]
52
+ #
53
+ def run
54
+ prep = prepare!
55
+ return prep if prep
56
+
57
+ sql = "REFRESH MATERIALIZED VIEW #{qualified_rel}"
58
+
59
+ conn.execute(sql)
60
+
61
+ payload = { view: "#{schema}.#{rel}" }
62
+ payload[:row_count] = fetch_rows_count if row_count_strategy.present?
63
+
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
+ )
77
+ end
78
+
79
+ private
80
+
81
+ # ────────────────────────────────────────────────────────────────
82
+ # internal
83
+ # ────────────────────────────────────────────────────────────────
84
+
85
+ ##
86
+ # Validate name and existence of the materialized view.
87
+ #
88
+ # @api private
89
+ # @return [MatViews::ServiceResponse, nil]
90
+ #
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]
107
+ #
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
114
+
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
132
+ end
133
+
134
+ ##
135
+ # Accurate count via `COUNT(*)`.
136
+ # Potentially slow on large materialized views.
137
+ #
138
+ # @api private
139
+ # @return [Integer]
140
+ #
141
+ def exact_rows_count
142
+ conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require 'securerandom'
9
+
10
+ module MatViews
11
+ module Services
12
+ ##
13
+ # Service that performs a **swap-style refresh** of a materialized view.
14
+ #
15
+ # Instead of locking the existing view, this strategy builds a new
16
+ # temporary materialized view and atomically swaps it in. This approach
17
+ # minimizes downtime and allows for safer rebuilds of large views.
18
+ #
19
+ # Steps:
20
+ # 1. Create a temporary MV from the provided SQL.
21
+ # 2. In a transaction: rename original → old, tmp → original, drop old.
22
+ # 3. Recreate declared unique indexes (if any).
23
+ #
24
+ # Supports optional row count strategies:
25
+ # - `:estimated` → approximate, using `pg_class.reltuples`
26
+ # - `:exact` → accurate, using `COUNT(*)`
27
+ # - `nil` → skip row count
28
+ #
29
+ # @return [MatViews::ServiceResponse]
30
+ #
31
+ # @example
32
+ # svc = MatViews::Services::SwapRefresh.new(defn, row_count_strategy: :exact)
33
+ # svc.run
34
+ #
35
+ class SwapRefresh < BaseService
36
+ ##
37
+ # Row count strategy (`:estimated`, `:exact`, `nil`).
38
+ #
39
+ # @return [Symbol, nil]
40
+ attr_reader :row_count_strategy
41
+
42
+ ##
43
+ # @param definition [MatViews::MatViewDefinition]
44
+ # @param row_count_strategy [Symbol, nil]
45
+ def initialize(definition, row_count_strategy: :estimated)
46
+ super(definition)
47
+ @row_count_strategy = row_count_strategy
48
+ end
49
+
50
+ ##
51
+ # Execute the swap refresh.
52
+ #
53
+ # @return [MatViews::ServiceResponse]
54
+ def run
55
+ prep = prepare!
56
+ return prep if prep
57
+
58
+ create_sql = %(CREATE MATERIALIZED VIEW #{q_tmp} AS #{definition.sql} WITH DATA)
59
+ steps = [create_sql]
60
+ conn.execute(create_sql)
61
+
62
+ steps.concat(swap_index)
63
+
64
+ payload = { view: "#{schema}.#{rel}" }
65
+ payload[:row_count] = fetch_rows_count if row_count_strategy.present?
66
+
67
+ ok(:updated, payload: payload, meta: { steps: steps, row_count_strategy: row_count_strategy, swap: true })
68
+ rescue StandardError => e
69
+ error_response(e,
70
+ meta: {
71
+ steps: steps,
72
+ backtrace: Array(e.backtrace),
73
+ row_count_strategy: row_count_strategy,
74
+ swap: true
75
+ },
76
+ payload: { view: "#{schema}.#{rel}" })
77
+ end
78
+
79
+ private
80
+
81
+ # ────────────────────────────────────────────────────────────────
82
+ # internal
83
+ # ────────────────────────────────────────────────────────────────
84
+
85
+ ##
86
+ # Ensure name validity and existence of original view.
87
+ #
88
+ # @api private
89
+ # @return [MatViews::ServiceResponse, nil]
90
+ def prepare!
91
+ return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
92
+ return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
93
+
94
+ nil
95
+ end
96
+
97
+ ##
98
+ # Perform rename/drop/index recreation in a transaction.
99
+ #
100
+ # @api private
101
+ # @return [Array<String>] SQL steps executed
102
+ def swap_index
103
+ steps = []
104
+ conn.transaction do
105
+ rename_orig_sql = %(ALTER MATERIALIZED VIEW #{qualified_rel} RENAME TO #{conn.quote_column_name(old_rel)})
106
+ steps << rename_orig_sql
107
+ conn.execute(rename_orig_sql)
108
+
109
+ rename_tmp_sql = %(ALTER MATERIALIZED VIEW #{q_tmp} RENAME TO #{conn.quote_column_name(rel)})
110
+ steps << rename_tmp_sql
111
+ conn.execute(rename_tmp_sql)
112
+
113
+ drop_old_sql = %(DROP MATERIALIZED VIEW #{q_old})
114
+ steps << drop_old_sql
115
+ conn.execute(drop_old_sql)
116
+
117
+ recreate_declared_unique_indexes!(schema:, rel:, steps:)
118
+ end
119
+ steps
120
+ end
121
+
122
+ ##
123
+ # Quote the temporary materialized view name.
124
+ #
125
+ # @api private
126
+ # @return [String] quoted temporary view name
127
+ def q_tmp
128
+ @q_tmp ||= conn.quote_table_name("#{schema}.#{tmp_rel}")
129
+ end
130
+
131
+ ##
132
+ # Quote the original materialized view name.
133
+ #
134
+ # @api private
135
+ # @return [String] quoted original view name
136
+ def q_old
137
+ @q_old ||= conn.quote_table_name("#{schema}.#{old_rel}")
138
+ end
139
+
140
+ ##
141
+ # Fully-qualified, safely-quoted temporary relation name.
142
+ #
143
+ # @api private
144
+ # @return [String]
145
+ def tmp_rel
146
+ @tmp_rel ||= "#{rel}__tmp_#{SecureRandom.hex(4)}"
147
+ end
148
+
149
+ ##
150
+ # Fully-qualified, safely-quoted old relation name.
151
+ #
152
+ # @api private
153
+ # @return [String]
154
+ def old_rel
155
+ @old_rel ||= "#{rel}__old_#{SecureRandom.hex(4)}"
156
+ end
157
+
158
+ ##
159
+ # Recreate declared unique indexes on the swapped-in view.
160
+ #
161
+ # @api private
162
+ # @param schema [String]
163
+ # @param rel [String]
164
+ # @param steps [Array<String>] collected SQL
165
+ def recreate_declared_unique_indexes!(schema:, rel:, steps:)
166
+ cols = Array(definition.unique_index_columns).map(&:to_s).reject(&:empty?)
167
+ return if cols.empty?
168
+
169
+ quoted_cols = cols.map { |c| conn.quote_column_name(c) }.join(', ')
170
+ idx_name = conn.quote_column_name("#{rel}_uniq_#{cols.join('_')}")
171
+ q_rel = conn.quote_table_name("#{schema}.#{rel}")
172
+
173
+ sql = %(CREATE UNIQUE INDEX #{idx_name} ON #{q_rel} (#{quoted_cols}))
174
+ steps << sql
175
+ conn.execute(sql)
176
+ end
177
+
178
+ # ────────────────────────────────────────────────────────────────
179
+ # rows counting
180
+ # ────────────────────────────────────────────────────────────────
181
+
182
+ ##
183
+ # Fetch the row count based on the configured strategy.
184
+ #
185
+ # @api private
186
+ # @return [Integer, nil]
187
+ def fetch_rows_count
188
+ case row_count_strategy
189
+ when :estimated then estimated_rows_count
190
+ when :exact then exact_rows_count
191
+ end
192
+ end
193
+
194
+ ##
195
+ # Approximate row count via `pg_class.reltuples`.
196
+ #
197
+ # @api private
198
+ # @return [Integer]
199
+ def estimated_rows_count
200
+ conn.select_value(<<~SQL).to_i
201
+ SELECT COALESCE(c.reltuples::bigint, 0)
202
+ FROM pg_class c
203
+ JOIN pg_namespace n ON n.oid = c.relnamespace
204
+ WHERE c.relkind IN ('m','r','p')
205
+ AND n.nspname = #{conn.quote(schema)}
206
+ AND c.relname = #{conn.quote(rel)}
207
+ LIMIT 1
208
+ SQL
209
+ end
210
+
211
+ ##
212
+ # Accurate row count via `COUNT(*)`.
213
+ #
214
+ # @api private
215
+ # @return [Integer]
216
+ def exact_rows_count
217
+ conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ module MatViews
9
+ ##
10
+ # Defines the version of the MatViews gem.
11
+ #
12
+ # This constant is used to track and publish gem releases.
13
+ # It follows [Semantic Versioning](https://semver.org/):
14
+ #
15
+ # - MAJOR: Incompatible API changes
16
+ # - MINOR: Backwards-compatible functionality
17
+ # - PATCH: Backwards-compatible bug fixes
18
+ #
19
+ # @return [String] the current gem version
20
+ VERSION = '0.1.2'
21
+ end