mat_views 0.1.2 → 0.2.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.
@@ -14,20 +14,27 @@ module MatViews
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)
27
+ # svc = MatViews::Services::CreateView.new(defn, **options)
24
28
  # response = svc.run
25
- # response.status # => :created or :noop
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
33
  # svc.run
30
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)
37
+ #
31
38
  class CreateView < BaseService
32
39
  ##
33
40
  # Whether to force recreation (drop+create if exists).
@@ -38,53 +45,53 @@ 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)
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)
43
56
  @force = !!force
44
57
  end
45
58
 
59
+ private
60
+
46
61
  ##
47
62
  # Execute the create operation.
48
63
  #
49
64
  # - Validates name, SQL, and concurrent-index requirements.
50
- # - Handles existing view: noop (default) or drop+recreate (`force: true`).
65
+ # - Handles existing view: skipped (default) or drop+recreate (`force: true`).
51
66
  # - Creates the materialized view WITH DATA.
52
67
  # - Creates a UNIQUE index if refresh strategy is concurrent.
53
68
  #
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
69
+ # @api private
58
70
  #
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!
71
+ # @return [MatViews::ServiceResponse]
72
+ # - `status: :created or :skipped` on success, with `response` containing:
73
+ # - `view` - the qualified view name
74
+ # - `row_count_before` - if requested and available
75
+ # - `row_count_after` - if requested and available
76
+ # - `status: :error` with `error` on failure, with `error` containing:
77
+ # - serlialized exception class, message, and backtrace in a hash
78
+ def _run
79
+ sql = create_with_data_sql
80
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
81
+ # If exists, either skipped or drop+recreate
82
+ existed = handle_existing
65
83
  return existed if existed.is_a?(MatViews::ServiceResponse)
66
84
 
67
- # Always create WITH DATA for a fresh view
68
- create_with_data
85
+ response[:row_count_before] = UNKNOWN_ROW_COUNT
86
+ conn.execute(sql)
87
+ response[:row_count_after] = fetch_rows_count
69
88
 
70
89
  # For concurrent strategy, ensure the unique index so future
71
90
  # 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
91
+ response.merge!(ensure_unique_index_if_needed)
84
92
 
85
- # ────────────────────────────────────────────────────────────────
86
- # internal
87
- # ────────────────────────────────────────────────────────────────
93
+ ok(:created)
94
+ end
88
95
 
89
96
  ##
90
97
  # Validate name, SQL, and concurrent strategy requirements.
@@ -92,37 +99,47 @@ module MatViews
92
99
  # @api private
93
100
  # @return [MatViews::ServiceResponse, nil] error response or nil if OK
94
101
  #
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?
102
+ def prepare
103
+ raise_err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
104
+ raise_err('SQL must start with SELECT') unless valid_sql?
105
+ raise_err('refresh_strategy=concurrent requires unique_index_columns (non-empty)') if strategy == 'concurrent' && cols.empty?
99
106
 
100
107
  nil
101
108
  end
102
109
 
103
110
  ##
104
- # Handle existing matview: return noop if not forcing, or drop if forcing.
111
+ # Assign the request parameters.
112
+ # Called by {#run} before {#prepare}.
113
+ #
114
+ # @api private
115
+ # @return [void]
116
+ #
117
+ def assign_request
118
+ self.request = { row_count_strategy: row_count_strategy, force: }
119
+ end
120
+
121
+ ##
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
@@ -11,24 +11,24 @@ module MatViews
11
11
  # Service that safely drops a PostgreSQL materialized 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)
23
+ # svc = MatViews::Services::DeleteView.new(defn, **options)
27
24
  # svc.run
28
25
  #
29
26
  # @example Force drop with CASCADE
30
27
  # MatViews::Services::DeleteView.new(defn, cascade: true).run
31
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
+ #
32
32
  class DeleteView < BaseService
33
33
  ##
34
34
  # Whether to cascade the drop (default: false).
@@ -36,98 +36,55 @@ 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 {#run} 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
  ##
@@ -136,22 +93,19 @@ module MatViews
136
93
  # @api private
137
94
  # @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?
96
+ def prepare
97
+ raise_err "Invalid view name format: #{definition.name.inspect}" unless valid_name?
144
98
 
145
99
  nil
146
100
  end
147
101
 
148
102
  ##
149
- # Construct DROP SQL with cascade/restrict options.
103
+ # Build the SQL DROP statement.
150
104
  #
151
105
  # @api private
152
106
  # @return [String]
153
107
  #
154
- def build_sql
108
+ def drop_sql
155
109
  drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
156
110
  %(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
157
111
  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.run
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,47 @@ 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
65
  # Validate name and existence of the materialized view.
87
66
  #
88
67
  # @api private
89
68
  # @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?
70
+ def prepare
71
+ raise_err "Invalid view name format: #{definition.name.inspect}" unless valid_name?
72
+ raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
94
73
 
95
74
  nil
96
75
  end
97
76
 
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
77
  ##
135
- # Accurate count via `COUNT(*)`.
136
- # Potentially slow on large materialized views.
78
+ # Assign the request parameters.
79
+ # Called by {#run} before {#prepare}.
137
80
  #
138
81
  # @api private
139
- # @return [Integer]
82
+ # @return [void]
140
83
  #
141
- def exact_rows_count
142
- conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
84
+ def assign_request
85
+ self.request = { row_count_strategy: row_count_strategy }
143
86
  end
144
87
  end
145
88
  end