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
data/lib/mat_views.rb ADDED
@@ -0,0 +1,57 @@
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 'mat_views/version'
9
+ require 'mat_views/engine'
10
+ require 'mat_views/configuration'
11
+ require 'mat_views/jobs/adapter'
12
+ require 'mat_views/service_response'
13
+ require 'mat_views/services/base_service'
14
+ require 'mat_views/services/create_view'
15
+ require 'mat_views/services/regular_refresh'
16
+ require 'mat_views/services/concurrent_refresh'
17
+ require 'mat_views/services/swap_refresh'
18
+ require 'mat_views/services/delete_view'
19
+
20
+ ##
21
+ # MatViews is a Rails engine that provides first-class support for
22
+ # PostgreSQL materialized views in Rails applications.
23
+ #
24
+ # Features include:
25
+ # - Declarative definitions for materialized views
26
+ # - Safe creation, refresh (regular, concurrent, swap), and deletion
27
+ # - Background job integration (ActiveJob, Sidekiq, Resque)
28
+ # - Tracking of run history and metrics
29
+ # - Rake task helpers for operational workflows
30
+ #
31
+ # Usage:
32
+ # MatViews.configure do |config|
33
+ # config.job_queue = :low_priority
34
+ # config.job_adapter = :sidekiq
35
+ # end
36
+ #
37
+ # Once mounted, Rails apps can leverage MatViews services and jobs
38
+ # to manage materialized views consistently.
39
+ module MatViews
40
+ class << self
41
+ # Global configuration for MatViews
42
+ # @return [MatViews::Configuration]
43
+ attr_accessor :configuration
44
+
45
+ # Configure MatViews via block.
46
+ #
47
+ # Example:
48
+ # MatViews.configure do |config|
49
+ # config.job_adapter = :sidekiq
50
+ # config.job_queue = :materialized
51
+ # end
52
+ def configure
53
+ self.configuration ||= Configuration.new
54
+ yield(configuration)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module MatViews
6
+ module Tasks
7
+ ##
8
+ # Helpers module provides utility methods for MatViews Rake tasks.
9
+ #
10
+ # These helpers support:
11
+ # - Database connections
12
+ # - Logging
13
+ # - Parsing boolean/flag-like arguments
14
+ # - Confirmation prompts
15
+ # - Enqueueing background jobs for create, refresh, and delete operations
16
+ # - Looking up materialized view definitions
17
+ #
18
+ # By extracting this logic, Rake tasks can remain clean and declarative.
19
+ module Helpers
20
+ module_function
21
+
22
+ # Returns the current database connection.
23
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
24
+ def mv_conn
25
+ ActiveRecord::Base.connection
26
+ end
27
+
28
+ # Returns the Rails logger.
29
+ # @return [Logger]
30
+ def logger
31
+ Rails.logger
32
+ end
33
+
34
+ # Check if a value is a "truthy" boolean-like string.
35
+ # Recognized: 1, true, yes, y, --yes
36
+ # @param value [String, Boolean, nil]
37
+ # @return [Boolean]
38
+ def booleanish_true?(value)
39
+ str = value.to_s.strip.downcase
40
+ %w[1 true yes y --yes].include?(str)
41
+ end
42
+
43
+ # Whether confirmation should be skipped (YES env or arg).
44
+ def skip_confirm?(arg)
45
+ booleanish_true?(arg || ENV.fetch('YES', nil))
46
+ end
47
+
48
+ # Parse whether force mode is enabled (FORCE env or arg).
49
+ def parse_force?(arg)
50
+ booleanish_true?(arg || ENV.fetch('FORCE', nil))
51
+ end
52
+
53
+ # Parse row count strategy from arg or ROW_COUNT_STRATEGY env.
54
+ # Defaults to :estimated if blank.
55
+ def parse_row_count_strategy(arg)
56
+ s = (arg || ENV.fetch('ROW_COUNT_STRATEGY', nil)).to_s.strip
57
+ return :estimated if s.empty?
58
+
59
+ s.to_sym
60
+ end
61
+
62
+ # Check if a materialized view exists in schema.
63
+ # @param rel [String] relation name
64
+ # @param schema [String] schema name
65
+ # @return [Boolean]
66
+ def matview_exists?(rel, schema: 'public')
67
+ mv_conn.select_value(<<~SQL).to_i.positive?
68
+ SELECT COUNT(*)
69
+ FROM pg_matviews
70
+ WHERE schemaname = #{mv_conn.quote(schema)} AND matviewname = #{mv_conn.quote(rel)}
71
+ SQL
72
+ end
73
+
74
+ # Find a MatViewDefinition by raw name (schema.rel or rel).
75
+ # Raises if none found or mismatch with DB presence.
76
+ #
77
+ # @param raw_name [String] schema-qualified or unqualified view name
78
+ # @return [MatViews::MatViewDefinition]
79
+ # @raise [RuntimeError] if no definition found or mismatch with DB
80
+ def find_definition_by_name!(raw_name)
81
+ raise 'view_name is required' if raw_name.nil? || raw_name.to_s.strip.empty?
82
+
83
+ schema, rel =
84
+ if raw_name.to_s.include?('.')
85
+ parts = raw_name.to_s.split('.', 2)
86
+ [parts[0], parts[1]]
87
+ else
88
+ [nil, raw_name.to_s]
89
+ end
90
+
91
+ defn = MatViews::MatViewDefinition.find_by(name: rel)
92
+ return defn if defn
93
+
94
+ if schema && matview_exists?(rel, schema: schema)
95
+ raise "Materialized view #{schema}.#{rel} exists, but no MatViews::MatViewDefinition record was found for name=#{rel.inspect}"
96
+ end
97
+
98
+ raise "No MatViews::MatViewDefinition found for #{raw_name.inspect}"
99
+ end
100
+
101
+ # Ask user to confirm a destructive action, unless skipped.
102
+ #
103
+ # @param message [String] confirmation message
104
+ # @param skip [Boolean] whether to skip confirmation
105
+ # @raise [RuntimeError] if user declines confirmation
106
+ # @return [void]
107
+ #
108
+ # If `skip` is true, logs the message and returns without prompting.
109
+ # Otherwise, prompts user for confirmation and raises if declined.
110
+ def confirm!(message, skip: false)
111
+ if skip
112
+ logger.info("[mat_views] #{message} — confirmation skipped.")
113
+ return
114
+ end
115
+
116
+ logger.info("[mat_views] #{message}")
117
+ $stdout.print('Proceed? [y/N]: ')
118
+ $stdout.flush
119
+ ans = $stdin.gets&.strip&.downcase
120
+ return if ans&.start_with?('y')
121
+
122
+ raise 'Aborted.'
123
+ end
124
+
125
+ # Enqueue a CreateView job for given definition.
126
+ #
127
+ # @param definition_id [Integer] MatViewDefinition ID
128
+ # @param force [Boolean] whether to force creation
129
+ # @return [void]
130
+ def enqueue_create!(definition_id, force)
131
+ q = MatViews.configuration.job_queue || :default
132
+ MatViews::Jobs::Adapter.enqueue(
133
+ MatViews::CreateViewJob,
134
+ queue: q,
135
+ args: [definition_id, force]
136
+ )
137
+ end
138
+
139
+ # Enqueue a RefreshView job for given definition.
140
+ #
141
+ # @param definition_id [Integer] MatViewDefinition ID
142
+ # @param row_count_strategy [Symbol] :estimated or :exact
143
+ # @return [void]
144
+ #
145
+ # This method allows scheduling a refresh operation with the specified row count strategy.
146
+ # It uses the configured job adapter to enqueue the job.
147
+ def enqueue_refresh!(definition_id, row_count_strategy)
148
+ q = MatViews.configuration.job_queue || :default
149
+ MatViews::Jobs::Adapter.enqueue(
150
+ MatViews::RefreshViewJob,
151
+ queue: q,
152
+ args: [definition_id, row_count_strategy]
153
+ )
154
+ end
155
+
156
+ # Parse cascade option (CASCADE env or arg).
157
+ #
158
+ # @param arg [String, Boolean, nil] argument or environment variable value
159
+ # @return [Boolean] true if cascade is enabled, false otherwise
160
+ #
161
+ # This method checks if the CASCADE option is set to true, allowing for cascading drops.
162
+ # It defaults to the value of the CASCADE environment variable if not provided.
163
+ def parse_cascade?(arg)
164
+ booleanish_true?(arg || ENV.fetch('CASCADE', nil))
165
+ end
166
+
167
+ # Enqueue a DeleteView job for given definition.
168
+ #
169
+ # @param definition_id [Integer] MatViewDefinition ID
170
+ # @param cascade [Boolean] whether to drop with CASCADE
171
+ # @return [void]
172
+ #
173
+ # This method schedules a job to delete the materialized view, optionally with CASCADE.
174
+ # It uses the configured job adapter to enqueue the job.
175
+ def enqueue_delete!(definition_id, cascade)
176
+ q = MatViews.configuration.job_queue || :default
177
+ MatViews::Jobs::Adapter.enqueue(
178
+ MatViews::DeleteViewJob,
179
+ queue: q,
180
+ args: [definition_id, cascade]
181
+ )
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative 'helpers'
5
+
6
+ # rubocop:disable Metrics/BlockLength
7
+ namespace :mat_views do
8
+ helpers = MatViews::Tasks::Helpers
9
+
10
+ # ───────────── CREATE ─────────────
11
+
12
+ desc 'Enqueue a CREATE for a specific view by its name (optionally schema-qualified)'
13
+ task :create_by_name, %i[view_name force yes] => :environment do |_t, args|
14
+ force = helpers.parse_force?(args[:force])
15
+ skip = helpers.skip_confirm?(args[:yes])
16
+ defn = helpers.find_definition_by_name!(args[:view_name])
17
+
18
+ helpers.confirm!("Enqueue CREATE for view=#{defn.name} (id=#{defn.id}), force=#{force}", skip: skip)
19
+ helpers.enqueue_create!(defn.id, force)
20
+ helpers.logger.info("[mat_views] Enqueued CreateViewJob for definition ##{defn.id} (#{defn.name}), force=#{force}")
21
+ end
22
+
23
+ desc 'Enqueue a CREATE for a specific view by its definition ID'
24
+ task :create_by_id, %i[definition_id force yes] => :environment do |_t, args|
25
+ raise 'mat_views:create_by_id requires a definition_id parameter' if args[:definition_id].to_s.strip.empty?
26
+
27
+ force = helpers.parse_force?(args[:force])
28
+ skip = helpers.skip_confirm?(args[:yes])
29
+
30
+ defn = MatViews::MatViewDefinition.find_by(id: args[:definition_id])
31
+ raise "No MatViews::MatViewDefinition found for id=#{args[:definition_id]}" unless defn
32
+
33
+ helpers.confirm!("Enqueue CREATE for id=#{defn.id} (#{defn.name}), force=#{force}", skip: skip)
34
+ helpers.enqueue_create!(defn.id, force)
35
+ helpers.logger.info("[mat_views] Enqueued CreateViewJob for definition ##{defn.id} (#{defn.name}), force=#{force}")
36
+ end
37
+
38
+ desc 'Enqueue CREATE jobs for ALL defined materialized views'
39
+ task :create_all, %i[force yes] => :environment do |_t, args|
40
+ force = helpers.parse_force?(args[:force])
41
+ skip = helpers.skip_confirm?(args[:yes])
42
+
43
+ scope = MatViews::MatViewDefinition.all
44
+ count = scope.count
45
+ if count.zero?
46
+ helpers.logger.info('[mat_views] No mat view definitions found.')
47
+ next
48
+ end
49
+
50
+ helpers.confirm!("Enqueue CREATE for ALL (#{count}) views, force=#{force}", skip: skip)
51
+ scope.find_each { |defn| helpers.enqueue_create!(defn.id, force) }
52
+ helpers.logger.info("[mat_views] Enqueued #{count} CreateViewJob(s), force=#{force}.")
53
+ end
54
+
55
+ # ───────────── REFRESH ─────────────
56
+
57
+ desc 'Enqueue a REFRESH for a specific view by its name (optionally schema-qualified)'
58
+ task :refresh_by_name, %i[view_name row_count_strategy yes] => :environment do |_t, args|
59
+ rcs = helpers.parse_row_count_strategy(args[:row_count_strategy])
60
+ skip = helpers.skip_confirm?(args[:yes])
61
+ defn = helpers.find_definition_by_name!(args[:view_name])
62
+
63
+ helpers.confirm!("Enqueue REFRESH for view=#{defn.name} (id=#{defn.id}), row_count_strategy=#{rcs}", skip: skip)
64
+ helpers.enqueue_refresh!(defn.id, rcs)
65
+ helpers.logger.info("[mat_views] Enqueued RefreshViewJob for definition ##{defn.id} (#{defn.name}), row_count_strategy=#{rcs}")
66
+ end
67
+
68
+ desc 'Enqueue a REFRESH for a specific view by its definition ID'
69
+ task :refresh_by_id, %i[definition_id row_count_strategy yes] => :environment do |_t, args|
70
+ raise 'mat_views:refresh_by_id requires a definition_id parameter' if args[:definition_id].to_s.strip.empty?
71
+
72
+ rcs = helpers.parse_row_count_strategy(args[:row_count_strategy])
73
+ skip = helpers.skip_confirm?(args[:yes])
74
+
75
+ defn = MatViews::MatViewDefinition.find_by(id: args[:definition_id])
76
+ raise "No MatViews::MatViewDefinition found for id=#{args[:definition_id]}" unless defn
77
+
78
+ helpers.confirm!("Enqueue REFRESH for id=#{defn.id} (#{defn.name}), row_count_strategy=#{rcs}", skip: skip)
79
+ helpers.enqueue_refresh!(defn.id, rcs)
80
+ helpers.logger.info("[mat_views] Enqueued RefreshViewJob for definition ##{defn.id} (#{defn.name}), row_count_strategy=#{rcs}")
81
+ end
82
+
83
+ desc 'Enqueue REFRESH jobs for ALL defined materialized views'
84
+ task :refresh_all, %i[row_count_strategy yes] => :environment do |_t, args|
85
+ rcs = helpers.parse_row_count_strategy(args[:row_count_strategy])
86
+ skip = helpers.skip_confirm?(args[:yes])
87
+
88
+ scope = MatViews::MatViewDefinition.all
89
+ count = scope.count
90
+ if count.zero?
91
+ helpers.logger.info('[mat_views] No mat view definitions found.')
92
+ next
93
+ end
94
+
95
+ helpers.confirm!("Enqueue REFRESH for ALL (#{count}) views, row_count_strategy=#{rcs}", skip: skip)
96
+ scope.find_each { |defn| helpers.enqueue_refresh!(defn.id, rcs) }
97
+ helpers.logger.info("[mat_views] Enqueued #{count} RefreshViewJob(s), row_count_strategy=#{rcs}.")
98
+ end
99
+
100
+ # ───────────── DELETE ─────────────
101
+
102
+ desc 'Enqueue a DELETE (DROP MATERIALIZED VIEW) for a specific view by its name (optionally schema-qualified)'
103
+ task :delete_by_name, %i[view_name cascade yes] => :environment do |_t, args|
104
+ cascade = helpers.parse_cascade?(args[:cascade])
105
+ skip = helpers.skip_confirm?(args[:yes])
106
+ defn = helpers.find_definition_by_name!(args[:view_name])
107
+
108
+ helpers.confirm!("Enqueue DELETE for view=#{defn.name} (id=#{defn.id}), cascade=#{cascade}", skip: skip)
109
+ helpers.enqueue_delete!(defn.id, cascade)
110
+ helpers.logger.info("[mat_views] Enqueued DeleteViewJob for definition ##{defn.id} (#{defn.name}), cascade=#{cascade}")
111
+ end
112
+
113
+ desc 'Enqueue a DELETE (DROP MATERIALIZED VIEW) for a specific view by its definition ID'
114
+ task :delete_by_id, %i[definition_id cascade yes] => :environment do |_t, args|
115
+ raise 'mat_views:delete_by_id requires a definition_id parameter' if args[:definition_id].to_s.strip.empty?
116
+
117
+ cascade = helpers.parse_cascade?(args[:cascade])
118
+ skip = helpers.skip_confirm?(args[:yes])
119
+
120
+ defn = MatViews::MatViewDefinition.find_by(id: args[:definition_id])
121
+ raise "No MatViews::MatViewDefinition found for id=#{args[:definition_id]}" unless defn
122
+
123
+ helpers.confirm!("Enqueue DELETE for id=#{defn.id} (#{defn.name}), cascade=#{cascade}", skip: skip)
124
+ helpers.enqueue_delete!(defn.id, cascade)
125
+ helpers.logger.info("[mat_views] Enqueued DeleteViewJob for definition ##{defn.id} (#{defn.name}), cascade=#{cascade}")
126
+ end
127
+
128
+ desc 'Enqueue DELETE jobs for ALL defined materialized views'
129
+ task :delete_all, %i[cascade yes] => :environment do |_t, args|
130
+ cascade = helpers.parse_cascade?(args[:cascade])
131
+ skip = helpers.skip_confirm?(args[:yes])
132
+
133
+ scope = MatViews::MatViewDefinition.all
134
+ count = scope.count
135
+ if count.zero?
136
+ helpers.logger.info('[mat_views] No mat view definitions found.')
137
+ next
138
+ end
139
+
140
+ helpers.confirm!("Enqueue DELETE for ALL (#{count}) views, cascade=#{cascade}", skip: skip)
141
+ scope.find_each { |defn| helpers.enqueue_delete!(defn.id, cascade) }
142
+ helpers.logger.info("[mat_views] Enqueued #{count} DeleteViewJob(s), cascade=#{cascade}.")
143
+ end
144
+ end
145
+ # rubocop:enable Metrics/BlockLength
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mat_views
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Nitesh Purohit
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ description: A mountable Rails engine to track, define, refresh, and monitor Postgres
33
+ materialized views.
34
+ email:
35
+ - nitesh.purohit.it@gmail.com
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - LICENSE
41
+ - README.md
42
+ - Rakefile
43
+ - app/assets/stylesheets/mat_views/application.css
44
+ - app/jobs/mat_views/application_job.rb
45
+ - app/jobs/mat_views/create_view_job.rb
46
+ - app/jobs/mat_views/delete_view_job.rb
47
+ - app/jobs/mat_views/refresh_view_job.rb
48
+ - app/models/mat_views/application_record.rb
49
+ - app/models/mat_views/mat_view_definition.rb
50
+ - app/models/mat_views/mat_view_run.rb
51
+ - config/routes.rb
52
+ - lib/generators/mat_views/install/install_generator.rb
53
+ - lib/generators/mat_views/install/templates/create_mat_view_definitions.rb
54
+ - lib/generators/mat_views/install/templates/create_mat_view_runs.rb
55
+ - lib/generators/mat_views/install/templates/mat_views_initializer.rb
56
+ - lib/mat_views.rb
57
+ - lib/mat_views/configuration.rb
58
+ - lib/mat_views/engine.rb
59
+ - lib/mat_views/jobs/adapter.rb
60
+ - lib/mat_views/service_response.rb
61
+ - lib/mat_views/services/base_service.rb
62
+ - lib/mat_views/services/concurrent_refresh.rb
63
+ - lib/mat_views/services/create_view.rb
64
+ - lib/mat_views/services/delete_view.rb
65
+ - lib/mat_views/services/regular_refresh.rb
66
+ - lib/mat_views/services/swap_refresh.rb
67
+ - lib/mat_views/version.rb
68
+ - lib/tasks/helpers.rb
69
+ - lib/tasks/mat_views_tasks.rake
70
+ homepage: https://github.com/Code-Vedas/rails_materialized_views
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/Code-Vedas/rails_materialized_views
75
+ source_code_uri: https://github.com/Code-Vedas/rails_materialized_views.git
76
+ changelog_uri: https://github.com/Code-Vedas/rails_materialized_views/blob/main/CHANGELOG.md
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '3.2'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.6.8
93
+ specification_version: 4
94
+ summary: Manage and refresh PostgreSQL materialized views in Rails
95
+ test_files: []