├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── controllers │ └── rails_pg_extras │ │ └── web │ │ ├── actions_controller.rb │ │ ├── application_controller.rb │ │ └── queries_controller.rb └── views │ ├── layouts │ └── rails_pg_extras │ │ └── web │ │ └── application.html.erb │ └── rails_pg_extras │ └── web │ ├── queries │ ├── _diagnose.html.erb │ ├── _result.html.erb │ ├── _unavailable_extensions_warning.html.erb │ ├── index.html.erb │ └── show.html.erb │ └── shared │ └── _queries_selector.html.erb ├── assets ├── pg-extras-rails-ujs.js └── pg-extras-tailwind.min.css ├── config └── routes.rb ├── docker-compose.yml.sample ├── lib ├── rails-pg-extras.rb └── rails_pg_extras │ ├── configuration.rb │ ├── diagnose_data.rb │ ├── diagnose_print.rb │ ├── index_info.rb │ ├── index_info_print.rb │ ├── mcp_app.rb │ ├── missing_fk_constraints.rb │ ├── missing_fk_indexes.rb │ ├── railtie.rb │ ├── table_info.rb │ ├── table_info_print.rb │ ├── tasks │ └── all.rake │ ├── version.rb │ ├── web.rb │ └── web │ └── engine.rb ├── marginalia-logs.png ├── pg-extras-mcp.png ├── pg-extras-ui-3.png ├── rails-pg-extras-diagnose.png ├── rails-pg-extras.gemspec └── spec ├── smoke_spec.rb └── spec_helper.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | assets/* linguist-vendored 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby-version: ['3.4', '3.3', '3.2', '3.1', '3.0', '2.7'] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Run PostgreSQL 12 19 | run: | 20 | docker run --env POSTGRES_USER=postgres \ 21 | --env POSTGRES_DB=rails-pg-extras-test \ 22 | --env POSTGRES_PASSWORD=secret \ 23 | -d -p 5432:5432 postgres:12.20-alpine \ 24 | postgres -c shared_preload_libraries=pg_stat_statements 25 | - name: Run PostgreSQL 13 26 | run: | 27 | docker run --env POSTGRES_USER=postgres \ 28 | --env POSTGRES_DB=rails-pg-extras-test \ 29 | --env POSTGRES_PASSWORD=secret \ 30 | -d -p 5433:5432 postgres:13.16-alpine \ 31 | postgres -c shared_preload_libraries=pg_stat_statements 32 | - name: Run PostgreSQL 14 33 | run: | 34 | docker run --env POSTGRES_USER=postgres \ 35 | --env POSTGRES_DB=rails-pg-extras-test \ 36 | --env POSTGRES_PASSWORD=secret \ 37 | -d -p 5434:5432 postgres:14.13-alpine \ 38 | postgres -c shared_preload_libraries=pg_stat_statements 39 | - name: Run PostgreSQL 15 40 | run: | 41 | docker run --env POSTGRES_USER=postgres \ 42 | --env POSTGRES_DB=rails-pg-extras-test \ 43 | --env POSTGRES_PASSWORD=secret \ 44 | -d -p 5435:5432 postgres:15.8-alpine \ 45 | postgres -c shared_preload_libraries=pg_stat_statements 46 | - name: Run PostgreSQL 16 47 | run: | 48 | docker run --env POSTGRES_USER=postgres \ 49 | --env POSTGRES_DB=rails-pg-extras-test \ 50 | --env POSTGRES_PASSWORD=secret \ 51 | -d -p 5436:5432 postgres:16.4-alpine \ 52 | postgres -c shared_preload_libraries=pg_stat_statements 53 | - name: Run PostgreSQL 17 54 | run: | 55 | docker run --env POSTGRES_USER=postgres \ 56 | --env POSTGRES_DB=rails-pg-extras-test \ 57 | --env POSTGRES_PASSWORD=secret \ 58 | -d -p 5437:5432 postgres:17.0-alpine \ 59 | postgres -c shared_preload_libraries=pg_stat_statements 60 | sleep 5 61 | - name: Set up Ruby ${{ matrix.ruby-version }} 62 | uses: ruby/setup-ruby@v1 63 | with: 64 | ruby-version: ${{ matrix.ruby-version }} 65 | - name: Setup dependencies 66 | run: | 67 | gem install bundler -v 2.4.22 68 | sudo apt-get update --allow-releaseinfo-change 69 | sudo apt install postgresql-client 70 | sudo apt install libpq-dev 71 | bundle config set --local path 'vendor/bundle' 72 | bundle install 73 | sleep 10 74 | - name: Run tests for PG 12 75 | env: 76 | PG_VERSION: 12 77 | run: | 78 | bundle exec rspec spec/ 79 | - name: Run tests for PG 13 80 | env: 81 | PG_VERSION: 13 82 | run: | 83 | bundle exec rspec spec/ 84 | - name: Run tests for PG 14 85 | env: 86 | PG_VERSION: 14 87 | run: | 88 | bundle exec rspec spec/ 89 | - name: Run tests for PG 15 90 | env: 91 | PG_VERSION: 15 92 | run: | 93 | bundle exec rspec spec/ 94 | - name: Run tests for PG 16 95 | env: 96 | PG_VERSION: 16 97 | run: | 98 | bundle exec rspec spec/ 99 | - name: Run tests for PG 17 100 | env: 101 | PG_VERSION: 17 102 | run: | 103 | bundle exec rspec spec/ 104 | 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .ruby-version 3 | pkg/ 4 | *.gem 5 | docker-compose.yml 6 | .byebug_history 7 | 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © Paweł Urbanek 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails PG Extras [![Gem Version](https://badge.fury.io/rb/rails-pg-extras.svg)](https://badge.fury.io/rb/rails-pg-extras) [![GH Actions](https://github.com/pawurb/rails-pg-extras/actions/workflows/ci.yml/badge.svg)](https://github.com/pawurb/rails-pg-extras/actions) 2 | 3 | Rails port of [Heroku PG Extras](https://github.com/heroku/heroku-pg-extras) with several additions and improvements. The goal of this project is to provide powerful insights into the PostgreSQL database for Ruby on Rails apps that are not using the Heroku PostgreSQL plugin. 4 | 5 | Included rake tasks and Ruby methods can be used to obtain information about a Postgres instance, that may be useful when analyzing performance issues. This includes information about locks, index usage, buffer cache hit ratios and vacuum statistics. Ruby API enables developers to easily integrate the tool into e.g. automatic monitoring tasks. 6 | 7 | You can read this blog post for detailed step by step tutorial on how to [optimize PostgreSQL using PG Extras library](https://pawelurbanek.com/postgresql-fix-performance). 8 | 9 | **Shameless plug:** rails-pg-extras is just one of the tools that I use when conducting [Rails performance audits](https://pawelurbanek.com/optimize-rails-performance). Check out my offer if you need help with optimizing your application. 10 | 11 | Optionally you can enable a visual interface: 12 | 13 | ![Web interface](https://github.com/pawurb/rails-pg-extras/raw/main/pg-extras-ui-3.png) 14 | 15 | [rails-pg-extras-mcp gem](https://github.com/pawurb/rails-pg-extras-mcp) provides an MCP (Model Context Protocol) interface enabling PostgreSQL metadata and performance analysis with an LLM support. 16 | 17 | ![LLM interface](https://github.com/pawurb/rails-pg-extras/raw/main/pg-extras-mcp.png) 18 | 19 | Alternative versions: 20 | 21 | - Core dependency - [Ruby](https://github.com/pawurb/ruby-pg-extras) 22 | 23 | - [Rust](https://github.com/pawurb/rust-pg-extras) 24 | 25 | - [NodeJS](https://github.com/pawurb/node-postgres-extras) 26 | 27 | - [Elixir](https://github.com/pawurb/ecto_psql_extras) 28 | 29 | - [Python Flask](https://github.com/nickjj/flask-pg-extras) 30 | 31 | - [Haskell](https://github.com/pawurb/haskell-pg-extras) 32 | 33 | ## Installation 34 | 35 | In your Gemfile 36 | 37 | ```ruby 38 | gem "rails-pg-extras" 39 | ``` 40 | 41 | `calls` and `outliers` queries require [pg_stat_statements](https://www.postgresql.org/docs/current/pgstatstatements.html) extension. 42 | 43 | You can check if it is enabled in your database by running: 44 | 45 | ```ruby 46 | RailsPgExtras.extensions 47 | ``` 48 | You should see the similar line in the output: 49 | 50 | ```bash 51 | | pg_stat_statements | 1.7 | 1.7 | track execution statistics of all SQL statements executed | 52 | ``` 53 | 54 | `ssl_used` requires `sslinfo` extension, and `buffercache_usage`/`buffercache_usage` queries need `pg_buffercache`. You can enable them all by running: 55 | 56 | ```ruby 57 | RailsPgExtras.add_extensions 58 | ``` 59 | 60 | By default a primary ActiveRecord database connection is used for running metadata queries, rake tasks and web UI. To connect to a different database you can specify an `ENV['RAILS_PG_EXTRAS_DATABASE_URL']` value in the following format: 61 | 62 | ```ruby 63 | ENV["RAILS_PG_EXTRAS_DATABASE_URL"] = "postgresql://postgres:secret@localhost:5432/database_name" 64 | ``` 65 | 66 | Alternatively, you can specify database URL with a method call: 67 | 68 | ```ruby 69 | RailsPgExtras.database_url = "postgresql://postgres:secret@localhost:5432/database_name" 70 | ``` 71 | 72 | ## Usage 73 | 74 | Each command can be used as a rake task, or a directly from the Ruby code. 75 | 76 | ```bash 77 | rake pg_extras:cache_hit 78 | ``` 79 | 80 | ```ruby 81 | RailsPgExtras.cache_hit 82 | ``` 83 | ```bash 84 | +----------------+------------------------+ 85 | | Index and table hit rate | 86 | +----------------+------------------------+ 87 | | name | ratio | 88 | +----------------+------------------------+ 89 | | index hit rate | 0.97796610169491525424 | 90 | | table hit rate | 0.96724294813466787989 | 91 | +----------------+------------------------+ 92 | ``` 93 | 94 | 95 | By default the ASCII table is displayed, to change to format you need to specify the `in_format` parameter (`[:display_table, :hash, :array, :raw]` options are available): 96 | 97 | ```ruby 98 | RailsPgExtras.cache_hit(in_format: :hash) => 99 | 100 | [{"name"=>"index hit rate", "ratio"=>"0.97796610169491525424"}, {"name"=>"table hit rate", "ratio"=>"0.96724294813466787989"}] 101 | 102 | RailsPgExtras.cache_hit(in_format: :array) => 103 | 104 | [["index hit rate", "0.97796610169491525424"], ["table hit rate", "0.96724294813466787989"]] 105 | 106 | RailsPgExtras.cache_hit(in_format: :raw) => 107 | 108 | # 109 | ``` 110 | 111 | Some methods accept an optional `args` param allowing you to customize queries: 112 | 113 | ```ruby 114 | RailsPgExtras.long_running_queries(args: { threshold: "200 milliseconds" }) 115 | 116 | ``` 117 | 118 | By default, queries target the `public` schema of the database. You can specify a different schema by passing the `schema` argument: 119 | 120 | ```ruby 121 | RailsPgExtras.table_cache_hit(args: { schema: "my_schema" }) 122 | ``` 123 | 124 | You can customize the default `public` schema by setting `ENV['PG_EXTRAS_SCHEMA']` value. 125 | 126 | ## Diagnose report 127 | 128 | The simplest way to start using pg-extras is to execute a `diagnose` method. It runs a set of checks and prints out a report highlighting areas that may require additional investigation: 129 | 130 | ```ruby 131 | RailsPgExtras.diagnose 132 | 133 | $ rake pg_extras:diagnose 134 | ``` 135 | 136 | ![Diagnose report](https://github.com/pawurb/rails-pg-extras/raw/main/rails-pg-extras-diagnose.png) 137 | 138 | Keep reading to learn about methods that `diagnose` uses under the hood. 139 | 140 | ## Visual interface 141 | 142 | You can enable UI using a Rails engine by adding the following code in `config/routes.rb`: 143 | 144 | ```ruby 145 | mount RailsPgExtras::Web::Engine, at: 'pg_extras' 146 | ``` 147 | 148 | You can enable HTTP basic auth by specifying `Rails.application.credentials.pg_extras.user` (or `RAILS_PG_EXTRAS_USER`) and `Rails.application.credentials.pg_extras.password` (or `RAILS_PG_EXTRAS_PASSWORD`) values. Authentication is mandatory unless you specify `RAILS_PG_EXTRAS_PUBLIC_DASHBOARD=true` or set `RailsPgExtras.configuration.public_dashboard = true`. 149 | 150 | You can configure available web actions in `config/initializers/rails_pg_extras.rb`: 151 | 152 | ```ruby 153 | RailsPgExtras.configure do |config| 154 | # Rails-pg-extras does not enable all the web actions by default. You can check all available actions via `RailsPgExtras::Web::ACTIONS`. 155 | # For example, you may want to enable the dangerous `kill_all` action. 156 | 157 | config.enabled_web_actions = %i[kill_all pg_stat_statements_reset add_extensions] 158 | end 159 | ``` 160 | 161 | ## Available methods 162 | 163 | ### `measure_queries` 164 | 165 | This method displays query types executed when running a provided Ruby snippet, with their avg., min., max., and total duration in miliseconds. It also outputs info about the snippet execution duration and the portion spent running SQL queries (`total_duration`/`sql_duration`). It can help debug N+1 issues and review the impact of configuring eager loading: 166 | 167 | ```ruby 168 | 169 | RailsPgExtras.measure_queries { User.limit(10).map(&:team) } 170 | 171 | {:count=>11, 172 | :queries=> 173 | {"SELECT \"users\".* FROM \"users\" LIMIT $1"=> 174 | {:count=>1, 175 | :total_duration=>1.9, 176 | :min_duration=>1.9, 177 | :max_duration=>1.9, 178 | :avg_duration=>1.9}, 179 | "SELECT \"teams\".* FROM \"teams\" WHERE \"teams\".\"id\" = $1 LIMIT $2"=> 180 | {:count=>10, 181 | :total_duration=>0.94, 182 | :min_duration=>0.62, 183 | :max_duration=>1.37, 184 | :avg_duration=>0.94}}, 185 | :total_duration=>13.35, 186 | :sql_duration=>11.34} 187 | 188 | RailsPgExtras.measure_queries { User.limit(10).includes(:team).map(&:team) } 189 | 190 | {:count=>2, 191 | :queries=> 192 | {"SELECT \"users\".* FROM \"users\" LIMIT $1"=> 193 | {:count=>1, 194 | :total_duration=>3.43, 195 | :min_duration=>3.43, 196 | :max_duration=>3.43, 197 | :avg_duration=>3.43}, 198 | "SELECT \"teams\".* FROM \"teams\" WHERE \"teams\".\"id\" IN ($1, $2, $3, $4, $5, $6, $7, $8)"=> 199 | {:count=>1, 200 | :total_duration=>2.59, 201 | :min_duration=>2.59, 202 | :max_duration=>2.59, 203 | :avg_duration=>2.59}}, 204 | :total_duration=>9.75, 205 | :sql_duration=>6.02} 206 | 207 | ``` 208 | 209 | Optionally, by including [Marginalia gem](https://github.com/basecamp/marginalia) and configuring it to display query backtraces: 210 | 211 | `config/development.rb` 212 | 213 | ```ruby 214 | 215 | Marginalia::Comment.components = [:line] 216 | 217 | ``` 218 | 219 | you can add this info to the output: 220 | 221 | ![Marginalia logs](https://github.com/pawurb/rails-pg-extras/raw/main/marginalia-logs.png) 222 | 223 | ### `missing_fk_indexes` 224 | 225 | This method lists columns likely to be foreign keys (i.e. column name ending in `_id` and related table exists) which don't have an index. It's recommended to always index foreign key columns because they are used for searching relation objects. 226 | 227 | You can add indexes on the columns returned by this query and later check if they are receiving scans using the [unused_indexes method](#unused_indexes). Please remember that each index decreases write performance and autovacuuming overhead, so be careful when adding multiple indexes to often updated tables. 228 | 229 | ```ruby 230 | RailsPgExtras.missing_fk_indexes(args: { table_name: "users" }) 231 | 232 | +---------------------------------+ 233 | | Missing foreign key indexes | 234 | +-------------------+-------------+ 235 | | table | column_name | 236 | +-------------------+-------------+ 237 | | feedbacks | team_id | 238 | | votes | user_id | 239 | +-------------------+-------------+ 240 | 241 | ``` 242 | 243 | `table_name` argument is optional, if omitted, the method will display missing fk indexes for all the tables. 244 | 245 | ## `missing_fk_constraints` 246 | 247 | Similarly to the previous method, this one shows columns likely to be foreign keys that don't have a corresponding foreign key constraint. Foreign key constraints improve data integrity in the database by preventing relations with nonexisting objects. You can read more about the benefits of using foreign keys [in this blog post](https://pawelurbanek.com/rails-postgresql-data-integrity). 248 | 249 | ```ruby 250 | RailsPgExtras.missing_fk_constraints(args: { table_name: "users" }) 251 | 252 | +---------------------------------+ 253 | | Missing foreign key constraints | 254 | +-------------------+-------------+ 255 | | table | column_name | 256 | +-------------------+-------------+ 257 | | feedbacks | team_id | 258 | | votes | user_id | 259 | +-------------------+-------------+ 260 | 261 | ``` 262 | 263 | `table_name` argument is optional, if omitted, method will display missing fk constraints for all the tables. 264 | 265 | ### `table_schema` 266 | 267 | This method displays structure of a selected table, listing its column names, together with types, null constraints, and default values. 268 | 269 | ```ruby 270 | RailsPgExtras.table_schema(args: { table_name: "users" }) 271 | 272 | +-----------------------------+-----------------------------+-------------+-----------------------------------+ 273 | | column_name | data_type | is_nullable | column_default | 274 | +-----------------------------+-----------------------------+-------------+-----------------------------------+ 275 | | id | bigint | NO | nextval('users_id_seq'::regclass) | 276 | | team_id | integer | NO | | 277 | | slack_id | character varying | NO | | 278 | | pseudonym | character varying | YES | | 279 | 280 | ``` 281 | 282 | ### `table_info` 283 | 284 | This method displays metadata metrics for all or a selected table. You can use it to check the table's size, its cache hit metrics, and whether it is correctly indexed. Many sequential scans or no index scans are potential indicators of misconfigured indexes. This method aggregates data provided by other methods in an easy to analyze summary format. 285 | 286 | ```ruby 287 | RailsPgExtras.table_info(args: { table_name: "users" }) 288 | 289 | | Table name | Table size | Table cache hit | Indexes cache hit | Estimated rows | Sequential scans | Indexes scans | 290 | +------------+------------+-------------------+--------------------+----------------+------------------+---------------+ 291 | | users | 2432 kB | 0.999966685701511 | 0.9988780464661853 | 16650 | 2128 | 512496 | 292 | 293 | ``` 294 | 295 | ### `index_info` 296 | 297 | This method returns summary info about database indexes. You can check index size, how often it is used and what percentage of its total size are NULL values. Like the previous method, it aggregates data from other helper methods in an easy-to-digest format. 298 | 299 | ```ruby 300 | 301 | RailsPgExtras.index_info(args: { table_name: "users" }) 302 | 303 | | Index name | Table name | Columns | Index size | Index scans | Null frac | 304 | +-------------------------------+------------+----------------+------------+-------------+-----------+ 305 | | users_pkey | users | id | 1152 kB | 163007 | 0.00% | 306 | | index_users_on_slack_id | users | slack_id | 1080 kB | 258870 | 0.00% | 307 | | index_users_on_team_id | users | team_id | 816 kB | 70962 | 0.00% | 308 | | index_users_on_uuid | users | uuid | 1032 kB | 0 | 0.00% | 309 | | index_users_on_block_uuid | users | block_uuid | 776 kB | 19502 | 100.00% | 310 | | index_users_on_api_auth_token | users | api_auth_token | 1744 kB | 156 | 0.00% | 311 | 312 | ``` 313 | 314 | ### `cache_hit` 315 | 316 | ```ruby 317 | RailsPgExtras.cache_hit 318 | 319 | $ rake pg_extras:cache_hit 320 | 321 | name | ratio 322 | ----------------+------------------------ 323 | index hit rate | 0.99957765013541945832 324 | table hit rate | 1.00 325 | (2 rows) 326 | ``` 327 | 328 | This command provides information on the efficiency of the buffer cache, for both index reads (`index hit rate`) as well as table reads (`table hit rate`). A low buffer cache hit ratio can be a sign that the Postgres instance is too small for the workload. 329 | 330 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 331 | 332 | ### `index_cache_hit` 333 | 334 | ```ruby 335 | 336 | RailsPgExtras.index_cache_hit 337 | 338 | $ rake pg_extras:index_cache_hit 339 | 340 | | name | buffer_hits | block_reads | total_read | ratio | 341 | +-----------------------+-------------+-------------+------------+-------------------+ 342 | | teams | 187665 | 109 | 187774 | 0.999419514948821 | 343 | | subscriptions | 5160 | 6 | 5166 | 0.99883855981417 | 344 | | plans | 5718 | 9 | 5727 | 0.998428496595076 | 345 | (truncated results for brevity) 346 | ``` 347 | 348 | The same as `cache_hit` with each table's indexes cache hit info displayed separately. 349 | 350 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 351 | 352 | ### `table_cache_hit` 353 | 354 | ```ruby 355 | 356 | RailsPgExtras.table_cache_hit 357 | 358 | $ rake pg_extras:table_cache_hit 359 | 360 | | name | buffer_hits | block_reads | total_read | ratio | 361 | +-----------------------+-------------+-------------+------------+-------------------+ 362 | | plans | 32123 | 2 | 32125 | 0.999937743190662 | 363 | | subscriptions | 95021 | 8 | 95029 | 0.999915815172211 | 364 | | teams | 171637 | 200 | 171837 | 0.99883610631005 | 365 | (truncated results for brevity) 366 | ``` 367 | 368 | The same as `cache_hit` with each table's cache hit info displayed seperately. 369 | 370 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 371 | 372 | ### `db_settings` 373 | 374 | ```ruby 375 | 376 | RailsPgExtras.db_settings 377 | 378 | $ rake pg_extras:db_settings 379 | 380 | name | setting | unit | 381 | ------------------------------+---------+------+ 382 | checkpoint_completion_target | 0.7 | | 383 | default_statistics_target | 100 | | 384 | effective_cache_size | 1350000 | 8kB | 385 | effective_io_concurrency | 1 | | 386 | (truncated results for brevity) 387 | 388 | ``` 389 | 390 | This method displays values for selected PostgreSQL settings. You can compare them with settings recommended by [PGTune](https://pgtune.leopard.in.ua/#/) and tweak values to improve performance. 391 | 392 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 393 | 394 | ### `ssl_used` 395 | 396 | ```ruby 397 | 398 | RailsPgExtras.ssl_used 399 | 400 | | ssl_is_used | 401 | +---------------------------------+ 402 | | t | 403 | 404 | ``` 405 | 406 | Returns boolean indicating if an encrypted SSL is currently used. Connecting to the database via an unencrypted connection is a critical security risk. 407 | 408 | ### `index_usage` 409 | 410 | ```ruby 411 | RailsPgExtras.index_usage 412 | 413 | $ rake pg_extras:index_usage 414 | 415 | relname | percent_of_times_index_used | rows_in_table 416 | ---------------------+-----------------------------+--------------- 417 | events | 65 | 1217347 418 | app_infos | 74 | 314057 419 | app_infos_user_info | 0 | 198848 420 | user_info | 5 | 94545 421 | delayed_jobs | 27 | 0 422 | (5 rows) 423 | ``` 424 | 425 | This command provides information on the efficiency of indexes, represented as what percentage of total scans were index scans. A low percentage can indicate under indexing, or wrong data being indexed. 426 | 427 | ### `locks` 428 | 429 | ```ruby 430 | RailsPgExtras.locks(args: { limit: 20 }) 431 | 432 | $ rake pg_extras:locks 433 | 434 | procpid | relname | transactionid | granted | query_snippet | mode | age | application | 435 | ---------+---------+---------------+---------+-----------------------+------------------------------------------------------ 436 | 31776 | | | t | in transaction | ExclusiveLock | 00:19:29.837898 | bin/rails 437 | 31776 | | 1294 | t | in transaction | RowExclusiveLock | 00:19:29.837898 | bin/rails 438 | 31912 | | | t | select * from hello; | ExclusiveLock | 00:19:17.94259 | bin/rails 439 | 3443 | | | t | +| ExclusiveLock | 00:00:00 | bin/sidekiq 440 | | | | | select +| | | 441 | | | | | pg_stat_activi | | | 442 | (4 rows) 443 | ``` 444 | 445 | This command displays queries that have taken out an exclusive lock on a relation. Exclusive locks typically prevent other operations on that relation from taking place, and can be a cause of "hung" queries that are waiting for a lock to be granted. 446 | 447 | [More info](https://pawelurbanek.com/postgresql-fix-performance#deadlocks) 448 | 449 | ### `all_locks` 450 | 451 | ```ruby 452 | RailsPgExtras.all_locks 453 | 454 | $ rake pg_extras:all_locks 455 | ``` 456 | 457 | This command displays all the current locks, regardless of their type. 458 | 459 | ### `outliers` 460 | 461 | ```ruby 462 | RailsPgExtras.outliers(args: { limit: 20 }) 463 | 464 | $ rake pg_extras:outliers 465 | 466 | qry | exec_time | prop_exec_time | ncalls | sync_io_time | avg_exec_ms 467 | -----------------------------------------+------------------+----------------+-------------+--------------+------------- 468 | SELECT * FROM archivable_usage_events.. | 154:39:26.431466 | 72.2% | 34,211,877 | 00:00:00 | 16.24 469 | COPY public.archivable_usage_events (.. | 50:38:33.198418 | 23.6% | 13 | 13:34:21.008 | 233845.63 470 | COPY public.usage_events (id, reporte.. | 02:32:16.335233 | 1.2% | 13 | 00:34:19.718 | 11689.72 471 | INSERT INTO usage_events (id, retaine.. | 01:42:59.436532 | 0.8% | 12,328,187 | 00:00:00 | 0.84 472 | SELECT * FROM usage_events WHERE (alp.. | 01:18:10.754354 | 0.6% | 102,114,301 | 00:00:00 | 0.77 473 | UPDATE usage_events SET reporter_id =.. | 00:52:35.683254 | 0.4% | 23,786,348 | 00:00:00 | 0.13 474 | (truncated results for brevity) 475 | ``` 476 | 477 | This command displays statements, obtained from `pg_stat_statements`, ordered by the amount of time to execute in aggregate. This includes the statement itself, the total execution time for that statement, the proportion of total execution time for all statements that statement has taken up, the number of times that statement has been called, the amount of time that statement spent on synchronous I/O (reading/writing from the file system), and the average execution time per call in milliseconds. 478 | 479 | Typically, an efficient query will have an appropriate ratio of calls to total execution time, with as little time spent on I/O as possible. Queries that have a high total execution time but low call count should be investigated to improve their performance. Queries that have a high proportion of execution time being spent on synchronous I/O should also be investigated. 480 | 481 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 482 | 483 | ### `calls` 484 | 485 | ```ruby 486 | RailsPgExtras.calls(args: { limit: 10 }) 487 | 488 | $ rake pg_extras:calls 489 | 490 | qry | exec_time | prop_exec_time | ncalls | sync_io_time | avg_exec_ms 491 | -----------------------------------------+------------------+----------------+-------------+--------------+------------- 492 | SELECT * FROM usage_events WHERE (alp.. | 01:18:11.073333 | 0.6% | 102,120,780 | 00:00:00 | 1 493 | BEGIN | 00:00:51.285988 | 0.0% | 47,288,662 | 00:00:00 | 0 494 | COMMIT | 00:00:52.31724 | 0.0% | 47,288,615 | 00:00:00 | 0 495 | SELECT * FROM archivable_usage_event.. | 154:39:26.431466 | 72.2% | 34,211,877 | 00:00:00 | 16 496 | UPDATE usage_events SET reporter_id =.. | 00:52:35.986167 | 0.4% | 23,788,388 | 00:00:00 | 0 497 | (truncated results for brevity) 498 | ``` 499 | 500 | This command is much like `pg:outliers`, but ordered by the number of times a statement has been called. 501 | 502 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 503 | 504 | ### `blocking` 505 | 506 | ```ruby 507 | RailsPgExtras.blocking 508 | 509 | $ rake pg_extras:blocking 510 | 511 | blocked_pid | blocking_statement | blocking_duration | blocking_pid | blocked_statement | blocked_duration 512 | -------------+--------------------------+-------------------+--------------+------------------------------------------------------------------------------------+------------------ 513 | 461 | select count(*) from app | 00:00:03.838314 | 15682 | UPDATE "app" SET "updated_at" = '2013-03-04 15:07:04.746688' WHERE "id" = 12823149 | 00:00:03.821826 514 | (1 row) 515 | ``` 516 | 517 | This command displays statements that are currently holding locks that other statements are waiting to be released. This can be used in conjunction with `pg:locks` to determine which statements need to be terminated in order to resolve lock contention. 518 | 519 | [More info](https://pawelurbanek.com/postgresql-fix-performance#deadlocks) 520 | 521 | ### `total_index_size` 522 | 523 | ```ruby 524 | RailsPgExtras.total_index_size 525 | 526 | $ rake pg_extras:total_index_size 527 | 528 | size 529 | ------- 530 | 28194 MB 531 | (1 row) 532 | ``` 533 | 534 | This command displays the total size of all indexes on the database, in MB. It is calculated by taking the number of pages (reported in `relpages`) and multiplying it by the page size (8192 bytes). 535 | 536 | ### `index_size` 537 | 538 | ```ruby 539 | RailsPgExtras.index_size 540 | 541 | $ rake pg_extras:index_size 542 | name | size | schema | 543 | ---------------------------------------------------------------+------------------- 544 | idx_activity_attemptable_and_type_lesson_enrollment | 5196 MB | public | 545 | index_enrollment_attemptables_by_attempt_and_last_in_group | 4045 MB | public | 546 | index_attempts_on_student_id | 2611 MB | public | 547 | enrollment_activity_attemptables_pkey | 2513 MB | custom | 548 | index_attempts_on_student_id_final_attemptable_type | 2466 MB | custom | 549 | attempts_pkey | 2466 MB | custom | 550 | index_attempts_on_response_id | 2404 MB | public | 551 | index_attempts_on_enrollment_id | 1957 MB | public | 552 | index_enrollment_attemptables_by_enrollment_activity_id | 1789 MB | public | 553 | enrollment_activities_pkey | 458 MB | public | 554 | (truncated results for brevity) 555 | ``` 556 | 557 | This command displays the size of each each index in the database, in MB. It is calculated by taking the number of pages (reported in `relpages`) and multiplying it by the page size (8192 bytes). 558 | 559 | ### `table_size` 560 | 561 | ```ruby 562 | RailsPgExtras.table_size 563 | 564 | $ rake pg_extras:table_size 565 | 566 | name | size | schema | 567 | ---------------------------------------------------------------+------------------- 568 | learning_coaches | 196 MB | public | 569 | states | 145 MB | public | 570 | grade_levels | 111 MB | custom | 571 | charities_customers | 73 MB | custom | 572 | charities | 66 MB | public | 573 | (truncated results for brevity) 574 | ``` 575 | 576 | This command displays the size of each table and materialized view in the database, in MB. It is calculated by using the system administration function `pg_table_size()`, which includes the size of the main data fork, free space map, visibility map and TOAST data. 577 | 578 | ### `table_indexes_size` 579 | 580 | ```ruby 581 | RailsPgExtras.table_indexes_size 582 | 583 | $ rake pg_extras:table_indexes_size 584 | 585 | table | indexes_size 586 | ---------------------------------------------------------------+-------------- 587 | learning_coaches | 153 MB 588 | states | 125 MB 589 | charities_customers | 93 MB 590 | charities | 16 MB 591 | grade_levels | 11 MB 592 | (truncated results for brevity) 593 | ``` 594 | 595 | This command displays the total size of indexes for each table and materialized view, in MB. It is calculated by using the system administration function `pg_indexes_size()`. 596 | 597 | ### `total_table_size` 598 | 599 | ```ruby 600 | RailsPgExtras.total_table_size 601 | 602 | $ rake pg_extras:total_table_size 603 | 604 | name | size 605 | ---------------------------------------------------------------+--------- 606 | learning_coaches | 349 MB 607 | states | 270 MB 608 | charities_customers | 166 MB 609 | grade_levels | 122 MB 610 | charities | 82 MB 611 | (truncated results for brevity) 612 | ``` 613 | 614 | This command displays the total size of each table and materialized view in the database, in MB. It is calculated by using the system administration function `pg_total_relation_size()`, which includes table size, total index size and TOAST data. 615 | 616 | ### `unused_indexes` 617 | 618 | ```ruby 619 | RailsPgExtras.unused_indexes(args: { max_scans: 50 }) 620 | 621 | $ rake pg_extras:unused_indexes 622 | 623 | table | index | index_size | index_scans 624 | ---------------------+--------------------------------------------+------------+------------- 625 | public.grade_levels | index_placement_attempts_on_grade_level_id | 97 MB | 0 626 | public.observations | observations_attrs_grade_resources | 33 MB | 0 627 | public.messages | user_resource_id_idx | 12 MB | 0 628 | (3 rows) 629 | ``` 630 | 631 | This command displays indexes that have < 50 scans recorded against them, and are greater than 5 pages in size, ordered by size relative to the number of index scans. This command is generally useful for eliminating indexes that are unused, which can impact write performance, as well as read performance should they occupy space in memory. 632 | 633 | [More info](https://pawelurbanek.com/postgresql-fix-performance#unused-indexes) 634 | 635 | ### `duplicate_indexes` 636 | 637 | ```ruby 638 | 639 | RailsPgExtras.duplicate_indexes 640 | 641 | | size | idx1 | idx2 | idx3 | idx4 | 642 | +------------+--------------+----------------+----------+-----------+ 643 | | 128 k | users_pkey | index_users_id | | | 644 | ``` 645 | 646 | This command displays multiple indexes that have the same set of columns, same opclass, expression and predicate - which make them equivalent. Usually it's safe to drop one of them. 647 | 648 | ### `null_indexes` 649 | 650 | ```ruby 651 | 652 | RailsPgExtras.null_indexes(args: { min_relation_size_mb: 10 }) 653 | 654 | $ rake pg_extras:null_indexes 655 | 656 | oid | index | index_size | unique | indexed_column | null_frac | expected_saving 657 | ---------+--------------------+------------+--------+----------------+-----------+----------------- 658 | 183764 | users_reset_token | 1445 MB | t | reset_token | 97.00% | 1401 MB 659 | 88732 | plan_cancelled_at | 539 MB | f | cancelled_at | 8.30% | 44 MB 660 | 9827345 | users_email | 18 MB | t | email | 28.67% | 5160 kB 661 | 662 | ``` 663 | 664 | This command displays indexes that contain `NULL` values. A high ratio of `NULL` values means that using a partial index excluding them will be beneficial in case they are not used for searching. 665 | 666 | [More info](https://pawelurbanek.com/postgresql-fix-performance#null-indexes) 667 | 668 | ### `seq_scans` 669 | 670 | ```ruby 671 | RailsPgExtras.seq_scans 672 | 673 | $ rake pg_extras:seq_scans 674 | 675 | name | count 676 | -----------------------------------+---------- 677 | learning_coaches | 44820063 678 | states | 36794975 679 | grade_levels | 13972293 680 | charities_customers | 8615277 681 | charities | 4316276 682 | messages | 3922247 683 | contests_customers | 2915972 684 | classroom_goals | 2142014 685 | (truncated results for brevity) 686 | ``` 687 | 688 | This command displays the number of sequential scans recorded against all tables, descending by count of sequential scans. Tables that have very high numbers of sequential scans may be under-indexed, and it may be worth investigating queries that read from these tables. 689 | 690 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 691 | 692 | ### `long_running_queries` 693 | 694 | ```ruby 695 | RailsPgExtras.long_running_queries(args: { threshold: "200 milliseconds" }) 696 | 697 | $ rake pg_extras:long_running_queries 698 | 699 | pid | duration | query 700 | -------+-----------------+--------------------------------------------------------------------------------------- 701 | 19578 | 02:29:11.200129 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1450645 LIMIT 1 702 | 19465 | 02:26:05.542653 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1889881 LIMIT 1 703 | 19632 | 02:24:46.962818 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1581884 LIMIT 1 704 | (truncated results for brevity) 705 | ``` 706 | 707 | This command displays currently running queries, that have been running for longer than 5 minutes, descending by duration. Very long running queries can be a source of multiple issues, such as preventing DDL statements completing or vacuum being unable to update `relfrozenxid`. 708 | 709 | ### `records_rank` 710 | 711 | ```ruby 712 | RailsPgExtras.records_rank 713 | 714 | $ rake pg_extras:records_rank 715 | 716 | name | estimated_count 717 | -----------------------------------+----------------- 718 | tastypie_apiaccess | 568891 719 | notifications_event | 381227 720 | core_todo | 178614 721 | core_comment | 123969 722 | notifications_notification | 102101 723 | django_session | 68078 724 | (truncated results for brevity) 725 | ``` 726 | 727 | This command displays an estimated count of rows per table, descending by estimated count. The estimated count is derived from `n_live_tup`, which is updated by vacuum operations. Due to the way `n_live_tup` is populated, sparse vs. dense pages can result in estimations that are significantly out from the real count of rows. 728 | 729 | ### `bloat` 730 | 731 | ```ruby 732 | RailsPgExtras.bloat 733 | 734 | $ rake pg_extras:bloat 735 | 736 | type | schemaname | object_name | bloat | waste 737 | -------+------------+-------------------------------+-------+---------- 738 | table | public | bloated_table | 1.1 | 98 MB 739 | table | public | other_bloated_table | 1.1 | 58 MB 740 | index | public | bloated_table::bloated_index | 3.7 | 34 MB 741 | table | public | clean_table | 0.2 | 3808 kB 742 | table | public | other_clean_table | 0.3 | 1576 kB 743 | (truncated results for brevity) 744 | ``` 745 | 746 | This command displays an estimation of table "bloat" – space allocated to a relation that is full of dead tuples, that has yet to be reclaimed. Tables that have a high bloat ratio, typically 10 or greater, should be investigated to see if vacuuming is aggressive enough, and can be a sign of high table churn. 747 | 748 | [More info](https://pawelurbanek.com/postgresql-fix-performance#bloat) 749 | 750 | ### `vacuum_stats` 751 | 752 | ```ruby 753 | RailsPgExtras.vacuum_stats 754 | 755 | $ rake pg_extras:vacuum_stats 756 | 757 | schema | table | last_vacuum | last_autovacuum | rowcount | dead_rowcount | autovacuum_threshold | expect_autovacuum 758 | --------+-----------------------+-------------+------------------+----------------+----------------+----------------------+------------------- 759 | public | log_table | | 2013-04-26 17:37 | 18,030 | 0 | 3,656 | 760 | public | data_table | | 2013-04-26 13:09 | 79 | 28 | 66 | 761 | public | other_table | | 2013-04-26 11:41 | 41 | 47 | 58 | 762 | public | queue_table | | 2013-04-26 17:39 | 12 | 8,228 | 52 | yes 763 | (truncated results for brevity) 764 | ``` 765 | 766 | This command displays statistics related to vacuum operations for each table, including an estimation of dead rows, last autovacuum and the current autovacuum threshold. This command can be useful when determining if current vacuum thresholds require adjustments, and to determine when the table was last vacuumed. 767 | 768 | ### `kill_all` 769 | 770 | ```ruby 771 | 772 | RailsPgExtras.kill_all 773 | 774 | ``` 775 | 776 | This commands kills all the currently active connections to the database. It can be useful as a last resort when your database is stuck in a deadlock. 777 | 778 | ### `kill_pid` 779 | 780 | ```ruby 781 | 782 | RailsPgExtras.kill_pid(args: { pid: 4657 }) 783 | 784 | ``` 785 | 786 | This commands kills currently active database connection by its `pid` number. You can use `connections` method to find the correct `pid` values. 787 | 788 | ### `pg_stat_statements_reset` 789 | 790 | ```ruby 791 | RailsPgExtras.pg_stat_statements_reset 792 | ``` 793 | 794 | This command discards all statistics gathered so far by pg_stat_statements. 795 | 796 | ### `buffercache_stats` 797 | 798 | ```ruby 799 | RailsPgExtras.buffercache_stats(args: { limit: 10 }) 800 | ``` 801 | 802 | This command shows the relations buffered in database share buffer, ordered by percentage taken. It also shows that how much of the whole relation is buffered. 803 | 804 | ### `buffercache_usage` 805 | 806 | ```ruby 807 | RailsPgExtras.buffercache_usage(args: { limit: 20 }) 808 | ``` 809 | 810 | This command calculates how many blocks from which table are currently cached. 811 | 812 | ### `extensions` 813 | 814 | ```ruby 815 | 816 | RailsPgExtras.extensions 817 | 818 | $ rake pg_extras:extensions 819 | 820 | | pg_stat_statements | 1.7 | 1.7 | track execution statistics of all SQL statements executed 821 | (truncated results for brevity) 822 | 823 | ``` 824 | 825 | This command lists all the currently installed and available PostgreSQL extensions. 826 | 827 | ### `connections` 828 | 829 | ```ruby 830 | 831 | RailsPgExtras.connections 832 | 833 | +----------------------------------------------------------------+ 834 | | Returns the list of all active database connections | 835 | +------------------+--------------------------+------------------+ 836 | | username | pid | client_address | application_name | 837 | +------------------+--------------------------+------------------+ 838 | | postgres | 15962 | 172.31.69.166/32 | sidekiq | 839 | | postgres | 16810 | 172.31.69.166/32 | bin/rails | 840 | +------------------+--------------------------+------------------+ 841 | 842 | ``` 843 | 844 | This command returns the list of all active database connections. 845 | 846 | ### mandelbrot 847 | 848 | ```ruby 849 | RailsPgExtras.mandelbrot 850 | 851 | $ rake pg_extras:mandelbrot 852 | ``` 853 | 854 | This command outputs the Mandelbrot set, calculated through SQL. 855 | 856 | ## Testing 857 | 858 | ```bash 859 | cp docker-compose.yml.sample docker-compose.yml 860 | docker compose up -d 861 | rake test_all 862 | ``` 863 | 864 | ## Query sources 865 | 866 | - [https://github.com/heroku/heroku-pg-extras](https://github.com/heroku/heroku-pg-extras) 867 | - [https://hakibenita.com/postgresql-unused-index-size](https://hakibenita.com/postgresql-unused-index-size) 868 | - [https://sites.google.com/site/itmyshare/database-tips-and-examples/postgres/useful-sqls-to-check-contents-of-postgresql-shared_buffer](https://sites.google.com/site/itmyshare/database-tips-and-examples/postgres/useful-sqls-to-check-contents-of-postgresql-shared_buffer) 869 | - [https://wiki.postgresql.org/wiki/Index_Maintenance](https://wiki.postgresql.org/wiki/Index_Maintenance) 870 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | desc "Test all PG versions" 7 | task :test_all do 8 | system("PG_VERSION=12 bundle exec rspec spec/ && PG_VERSION=13 bundle exec rspec spec/ && PG_VERSION=14 bundle exec rspec spec/ && PG_VERSION=15 bundle exec rspec spec/ && PG_VERSION=16 bundle exec rspec spec/ && PG_VERSION=17 bundle exec rspec spec/") 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/rails_pg_extras/web/actions_controller.rb: -------------------------------------------------------------------------------- 1 | module RailsPgExtras::Web 2 | class ActionsController < RailsPgExtras::Web::ApplicationController 3 | before_action :validate_action! 4 | 5 | def kill_all 6 | run(:kill_all) 7 | end 8 | 9 | def pg_stat_statements_reset 10 | run(:pg_stat_statements_reset) 11 | end 12 | 13 | def add_extensions 14 | run(:add_extensions) 15 | end 16 | 17 | private 18 | 19 | def validate_action! 20 | unless RailsPgExtras::Web.action_enabled?(action_name) 21 | render plain: "Action '#{action_name}' is not enabled!", status: :forbidden 22 | end 23 | end 24 | 25 | def run(action) 26 | begin 27 | RailsPgExtras.run_query(query_name: action, in_format: :raw) 28 | redirect_to root_path, notice: "Successfully ran #{action}" 29 | rescue ActiveRecord::StatementInvalid => e 30 | redirect_to root_path, alert: "Error: #{e.message}" 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/controllers/rails_pg_extras/web/application_controller.rb: -------------------------------------------------------------------------------- 1 | require "rails-pg-extras" 2 | require "rails_pg_extras/version" 3 | 4 | module RailsPgExtras::Web 5 | class ApplicationController < ActionController::Base 6 | def self.get_user 7 | Rails.application.try(:credentials).try(:pg_extras).try(:user) || ENV["RAILS_PG_EXTRAS_USER"] 8 | end 9 | 10 | def self.get_password 11 | Rails.application.try(:credentials).try(:pg_extras).try(:password) || ENV["RAILS_PG_EXTRAS_PASSWORD"] 12 | end 13 | 14 | before_action :validate_credentials! 15 | layout "rails_pg_extras/web/application" 16 | 17 | REQUIRED_EXTENSIONS = { 18 | pg_stat_statements: %i[calls outliers pg_stat_statements_reset], 19 | pg_buffercache: %i[buffercache_stats buffercache_usage], 20 | sslinfo: %i[ssl_used], 21 | } 22 | 23 | ACTIONS = %i[kill_all pg_stat_statements_reset add_extensions] 24 | 25 | if get_user.present? && get_password.present? 26 | http_basic_authenticate_with name: get_user, password: get_password 27 | end 28 | 29 | def validate_credentials! 30 | if (self.class.get_user.blank? || self.class.get_password.blank?) && RailsPgExtras.configuration.public_dashboard != true 31 | raise "Missing credentials for rails-pg-extras dashboard! If you want to enable public dashboard please set RAILS_PG_EXTRAS_PUBLIC_DASHBOARD=true" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/controllers/rails_pg_extras/web/queries_controller.rb: -------------------------------------------------------------------------------- 1 | module RailsPgExtras::Web 2 | class QueriesController < RailsPgExtras::Web::ApplicationController 3 | before_action :load_queries 4 | helper_method :unavailable_extensions 5 | 6 | def index 7 | if params[:query_name].present? 8 | @query_name = params[:query_name].to_sym.presence_in(@all_queries.keys) 9 | return unless @query_name 10 | 11 | begin 12 | @result = RailsPgExtras.run_query(query_name: @query_name.to_sym, in_format: :raw) 13 | rescue ActiveRecord::StatementInvalid => e 14 | @error = e.message 15 | end 16 | 17 | render :show 18 | end 19 | end 20 | 21 | private 22 | 23 | SKIP_QUERIES = %i[table_schema table_foreign_keys].freeze 24 | 25 | def load_queries 26 | @all_queries = (RailsPgExtras::QUERIES - RailsPgExtras::Web::ACTIONS - SKIP_QUERIES).inject({}) do |memo, query_name| 27 | unless query_name.in? %i[mandelbrot] 28 | memo[query_name] = { disabled: query_disabled?(query_name) } 29 | end 30 | 31 | memo 32 | end 33 | end 34 | 35 | def query_disabled?(query_name) 36 | unavailable_extensions.values.flatten.include?(query_name) 37 | end 38 | 39 | def unavailable_extensions 40 | return @unavailable_extensions if defined?(@unavailable_extensions) 41 | 42 | enabled_extensions = ActiveRecord::Base.connection.extensions.lazy 43 | @unavailable_extensions = REQUIRED_EXTENSIONS.delete_if { |ext| enabled_extensions.grep(/^([^.]+\.)?#{ext}$/).any? } 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/views/layouts/rails_pg_extras/web/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for :title %> | v<%= RailsPgExtras::VERSION %> 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | <% if flash[:notice] %> 11 | 15 | <% end %> 16 | 17 | <% if flash[:alert] %> 18 | 22 | <% end %> 23 | 24 | <%= yield %> 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/queries/_diagnose.html.erb: -------------------------------------------------------------------------------- 1 |

Diagnose

2 |
3 | (Tutorial) 5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% RailsPgExtras.diagnose(in_format: :hash).each do |diagnosis| %> 17 | hover:bg-gray-300 transition"> 18 | 19 | 20 | 21 | 22 | <% end %> 23 | 24 |
OkCheck NameMessage
<%= diagnosis[:ok] ? "YES" : "NO" %><%= diagnosis[:check_name] %><%= diagnosis[:message] %>
25 |
26 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/queries/_result.html.erb: -------------------------------------------------------------------------------- 1 |

<%= title %>

2 | 3 |
4 | 5 | 6 | 7 | <% headers.each do |header| %> 8 | 9 | <% end %> 10 | 11 | 12 | 13 | <% rows.each.with_index do |row, i| %> 14 | "> 15 | <% row.each do |column| %> 16 | 17 | <% end %> 18 | 19 | <% end %> 20 | 21 |
<%= header %>
<%= column %>
22 |
23 | 24 |

Run at: <%= Time.now.utc %>

25 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/queries/_unavailable_extensions_warning.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% unavailable_extensions.each do |extension, queries| %> 3 | WARNING: Queries <%= queries.map { |q| "#{q}" }.join(", ").html_safe %> require extension: <%= extension %> 4 |
5 | <% end %> 6 |
7 | 8 | <% if RailsPgExtras::Web.action_enabled?(:add_extensions) %> 9 | <%= link_to "Enable extensions", add_extensions_action_path, 10 | method: "post", 11 | data: { 12 | confirm: "This command will enable following extensions: #{unavailable_extensions.keys.join(", ")}. Do you want to proceeed?", 13 | }, class: "border p-3 bg-green-500 text-white hover:bg-green-600 font-bold rounded" %> 14 | <% end %> 15 | 16 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/queries/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= content_for :title, "pg_extras" %> 2 | <%= render "rails_pg_extras/web/shared/queries_selector" %> 3 | <%= render "unavailable_extensions_warning" if unavailable_extensions.any? %> 4 | <%= render "diagnose" %> 5 | 6 |
7 |
8 |

Actions

9 | 10 | <% if RailsPgExtras::Web.action_enabled?(:kill_all) %> 11 | <%= link_to "kill_all", kill_all_action_path, 12 | method: "post", 13 | data: { 14 | confirm: "This commands kills all the currently active connections to the database. Do you want to proceed?", 15 | }, 16 | class: "border p-3 bg-red-500 text-white hover:bg-red-600 font-bold rounded" %> 17 | <% end %> 18 | 19 | <% if RailsPgExtras::Web.action_enabled?(:pg_stat_statements_reset) && unavailable_extensions.exclude?(:pg_stat_statements) %> 20 | <%= link_to "pg_stat_statements_reset", pg_stat_statements_reset_action_path, 21 | method: "post", 22 | data: { 23 | confirm: "This command discards all statistics gathered so far by pg_stat_statements. Do you want to proceed?", 24 | }, class: "border p-3 bg-blue-500 text-white hover:bg-blue-600 font-bold rounded" %> 25 | <% end %> 26 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/queries/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= content_for :title, params[:query_name].presence || "pg_extras" %> 2 | <%= render "rails_pg_extras/web/shared/queries_selector" %> 3 | 4 |
5 | 6 | <%= link_to "← Back to Diagnose", queries_path, 7 | class: "inline-block bg-blue-500 text-white font-medium px-4 py-2 rounded-lg shadow-md hover:bg-blue-600 transition" %> 8 | 9 | <% if @error %> 10 |
<%= @error %>
11 | <% else %> 12 | <% if @result&.any? %> 13 | <%= render "result", 14 | title: RubyPgExtras.description_for(query_name: @query_name), 15 | headers: @result[0].keys, 16 | rows: @result.values %> 17 | <% else %> 18 |
No results
19 | <% end %> 20 | <% end %> 21 | 22 | 29 | -------------------------------------------------------------------------------- /app/views/rails_pg_extras/web/shared/_queries_selector.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag queries_path, id: "queries", method: :get do |f| %> 2 | <%= select_tag :query_name, options_for_select(@all_queries, params[:query_name]), 3 | { prompt: "diagnose", class: "border p-2 font-bold", autofocus: true } %> 4 | <% end %> 5 | 6 | <%= javascript_tag nonce: true do -%> 7 | document.getElementById('queries').addEventListener('change', (e) => { 8 | e.target.form.submit() 9 | }) 10 | <% end -%> 11 | -------------------------------------------------------------------------------- /assets/pg-extras-rails-ujs.js: -------------------------------------------------------------------------------- 1 | /* 2 | Unobtrusive JavaScript 3 | https://github.com/rails/rails/blob/main/actionview/app/javascript 4 | Released under the MIT license 5 | */ 6 | (function (global, factory) { 7 | typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define(factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, 8 | global.Rails = factory()); 9 | })(this, (function () { 10 | "use strict"; 11 | const linkClickSelector = "a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]"; 12 | const buttonClickSelector = { 13 | selector: "button[data-remote]:not([form]), button[data-confirm]:not([form])", 14 | exclude: "form button" 15 | }; 16 | const inputChangeSelector = "select[data-remote], input[data-remote], textarea[data-remote]"; 17 | const formSubmitSelector = "form:not([data-turbo=true])"; 18 | const formInputClickSelector = "form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])"; 19 | const formDisableSelector = "input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled"; 20 | const formEnableSelector = "input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled"; 21 | const fileInputSelector = "input[name][type=file]:not([disabled])"; 22 | const linkDisableSelector = "a[data-disable-with], a[data-disable]"; 23 | const buttonDisableSelector = "button[data-remote][data-disable-with], button[data-remote][data-disable]"; 24 | let nonce = null; 25 | const loadCSPNonce = () => { 26 | const metaTag = document.querySelector("meta[name=csp-nonce]"); 27 | return nonce = metaTag && metaTag.content; 28 | }; 29 | const cspNonce = () => nonce || loadCSPNonce(); 30 | const m = Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.webkitMatchesSelector; 31 | const matches = function (element, selector) { 32 | if (selector.exclude) { 33 | return m.call(element, selector.selector) && !m.call(element, selector.exclude); 34 | } else { 35 | return m.call(element, selector); 36 | } 37 | }; 38 | const EXPANDO = "_ujsData"; 39 | const getData = (element, key) => element[EXPANDO] ? element[EXPANDO][key] : undefined; 40 | const setData = function (element, key, value) { 41 | if (!element[EXPANDO]) { 42 | element[EXPANDO] = {}; 43 | } 44 | return element[EXPANDO][key] = value; 45 | }; 46 | const $ = selector => Array.prototype.slice.call(document.querySelectorAll(selector)); 47 | const isContentEditable = function (element) { 48 | var isEditable = false; 49 | do { 50 | if (element.isContentEditable) { 51 | isEditable = true; 52 | break; 53 | } 54 | element = element.parentElement; 55 | } while (element); 56 | return isEditable; 57 | }; 58 | const csrfToken = () => { 59 | const meta = document.querySelector("meta[name=csrf-token]"); 60 | return meta && meta.content; 61 | }; 62 | const csrfParam = () => { 63 | const meta = document.querySelector("meta[name=csrf-param]"); 64 | return meta && meta.content; 65 | }; 66 | const CSRFProtection = xhr => { 67 | const token = csrfToken(); 68 | if (token) { 69 | return xhr.setRequestHeader("X-CSRF-Token", token); 70 | } 71 | }; 72 | const refreshCSRFTokens = () => { 73 | const token = csrfToken(); 74 | const param = csrfParam(); 75 | if (token && param) { 76 | return $('form input[name="' + param + '"]').forEach((input => input.value = token)); 77 | } 78 | }; 79 | const AcceptHeaders = { 80 | "*": "*/*", 81 | text: "text/plain", 82 | html: "text/html", 83 | xml: "application/xml, text/xml", 84 | json: "application/json, text/javascript", 85 | script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" 86 | }; 87 | const ajax = options => { 88 | options = prepareOptions(options); 89 | var xhr = createXHR(options, (function () { 90 | const response = processResponse(xhr.response != null ? xhr.response : xhr.responseText, xhr.getResponseHeader("Content-Type")); 91 | if (Math.floor(xhr.status / 100) === 2) { 92 | if (typeof options.success === "function") { 93 | options.success(response, xhr.statusText, xhr); 94 | } 95 | } else { 96 | if (typeof options.error === "function") { 97 | options.error(response, xhr.statusText, xhr); 98 | } 99 | } 100 | return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined; 101 | })); 102 | if (options.beforeSend && !options.beforeSend(xhr, options)) { 103 | return false; 104 | } 105 | if (xhr.readyState === XMLHttpRequest.OPENED) { 106 | return xhr.send(options.data); 107 | } 108 | }; 109 | var prepareOptions = function (options) { 110 | options.url = options.url || location.href; 111 | options.type = options.type.toUpperCase(); 112 | if (options.type === "GET" && options.data) { 113 | if (options.url.indexOf("?") < 0) { 114 | options.url += "?" + options.data; 115 | } else { 116 | options.url += "&" + options.data; 117 | } 118 | } 119 | if (!(options.dataType in AcceptHeaders)) { 120 | options.dataType = "*"; 121 | } 122 | options.accept = AcceptHeaders[options.dataType]; 123 | if (options.dataType !== "*") { 124 | options.accept += ", */*; q=0.01"; 125 | } 126 | return options; 127 | }; 128 | var createXHR = function (options, done) { 129 | const xhr = new XMLHttpRequest; 130 | xhr.open(options.type, options.url, true); 131 | xhr.setRequestHeader("Accept", options.accept); 132 | if (typeof options.data === "string") { 133 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); 134 | } 135 | if (!options.crossDomain) { 136 | xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); 137 | CSRFProtection(xhr); 138 | } 139 | xhr.withCredentials = !!options.withCredentials; 140 | xhr.onreadystatechange = function () { 141 | if (xhr.readyState === XMLHttpRequest.DONE) { 142 | return done(xhr); 143 | } 144 | }; 145 | return xhr; 146 | }; 147 | var processResponse = function (response, type) { 148 | if (typeof response === "string" && typeof type === "string") { 149 | if (type.match(/\bjson\b/)) { 150 | try { 151 | response = JSON.parse(response); 152 | } catch (error) { } 153 | } else if (type.match(/\b(?:java|ecma)script\b/)) { 154 | const script = document.createElement("script"); 155 | script.setAttribute("nonce", cspNonce()); 156 | script.text = response; 157 | document.head.appendChild(script).parentNode.removeChild(script); 158 | } else if (type.match(/\b(xml|html|svg)\b/)) { 159 | const parser = new DOMParser; 160 | type = type.replace(/;.+/, ""); 161 | try { 162 | response = parser.parseFromString(response, type); 163 | } catch (error1) { } 164 | } 165 | } 166 | return response; 167 | }; 168 | const href = element => element.href; 169 | const isCrossDomain = function (url) { 170 | const originAnchor = document.createElement("a"); 171 | originAnchor.href = location.href; 172 | const urlAnchor = document.createElement("a"); 173 | try { 174 | urlAnchor.href = url; 175 | return !((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host || originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host); 176 | } catch (e) { 177 | return true; 178 | } 179 | }; 180 | let preventDefault; 181 | let { CustomEvent: CustomEvent } = window; 182 | if (typeof CustomEvent !== "function") { 183 | CustomEvent = function (event, params) { 184 | const evt = document.createEvent("CustomEvent"); 185 | evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); 186 | return evt; 187 | }; 188 | CustomEvent.prototype = window.Event.prototype; 189 | ({ preventDefault: preventDefault } = CustomEvent.prototype); 190 | CustomEvent.prototype.preventDefault = function () { 191 | const result = preventDefault.call(this); 192 | if (this.cancelable && !this.defaultPrevented) { 193 | Object.defineProperty(this, "defaultPrevented", { 194 | get() { 195 | return true; 196 | } 197 | }); 198 | } 199 | return result; 200 | }; 201 | } 202 | const fire = (obj, name, data) => { 203 | const event = new CustomEvent(name, { 204 | bubbles: true, 205 | cancelable: true, 206 | detail: data 207 | }); 208 | obj.dispatchEvent(event); 209 | return !event.defaultPrevented; 210 | }; 211 | const stopEverything = e => { 212 | fire(e.target, "ujs:everythingStopped"); 213 | e.preventDefault(); 214 | e.stopPropagation(); 215 | e.stopImmediatePropagation(); 216 | }; 217 | const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, (function (e) { 218 | let { target: target } = e; 219 | while (!!(target instanceof Element) && !matches(target, selector)) { 220 | target = target.parentNode; 221 | } 222 | if (target instanceof Element && handler.call(target, e) === false) { 223 | e.preventDefault(); 224 | e.stopPropagation(); 225 | } 226 | })); 227 | const toArray = e => Array.prototype.slice.call(e); 228 | const serializeElement = (element, additionalParam) => { 229 | let inputs = [element]; 230 | if (matches(element, "form")) { 231 | inputs = toArray(element.elements); 232 | } 233 | const params = []; 234 | inputs.forEach((function (input) { 235 | if (!input.name || input.disabled) { 236 | return; 237 | } 238 | if (matches(input, "fieldset[disabled] *")) { 239 | return; 240 | } 241 | if (matches(input, "select")) { 242 | toArray(input.options).forEach((function (option) { 243 | if (option.selected) { 244 | params.push({ 245 | name: input.name, 246 | value: option.value 247 | }); 248 | } 249 | })); 250 | } else if (input.checked || ["radio", "checkbox", "submit"].indexOf(input.type) === -1) { 251 | params.push({ 252 | name: input.name, 253 | value: input.value 254 | }); 255 | } 256 | })); 257 | if (additionalParam) { 258 | params.push(additionalParam); 259 | } 260 | return params.map((function (param) { 261 | if (param.name) { 262 | return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`; 263 | } else { 264 | return param; 265 | } 266 | })).join("&"); 267 | }; 268 | const formElements = (form, selector) => { 269 | if (matches(form, "form")) { 270 | return toArray(form.elements).filter((el => matches(el, selector))); 271 | } else { 272 | return toArray(form.querySelectorAll(selector)); 273 | } 274 | }; 275 | const handleConfirmWithRails = rails => function (e) { 276 | if (!allowAction(this, rails)) { 277 | stopEverything(e); 278 | } 279 | }; 280 | const confirm = (message, element) => window.confirm(message); 281 | var allowAction = function (element, rails) { 282 | let callback; 283 | const message = element.getAttribute("data-confirm"); 284 | if (!message) { 285 | return true; 286 | } 287 | let answer = false; 288 | if (fire(element, "confirm")) { 289 | try { 290 | answer = rails.confirm(message, element); 291 | } catch (error) { } 292 | callback = fire(element, "confirm:complete", [answer]); 293 | } 294 | return answer && callback; 295 | }; 296 | const handleDisabledElement = function (e) { 297 | const element = this; 298 | if (element.disabled) { 299 | stopEverything(e); 300 | } 301 | }; 302 | const enableElement = e => { 303 | let element; 304 | if (e instanceof Event) { 305 | if (isXhrRedirect(e)) { 306 | return; 307 | } 308 | element = e.target; 309 | } else { 310 | element = e; 311 | } 312 | if (isContentEditable(element)) { 313 | return; 314 | } 315 | if (matches(element, linkDisableSelector)) { 316 | return enableLinkElement(element); 317 | } else if (matches(element, buttonDisableSelector) || matches(element, formEnableSelector)) { 318 | return enableFormElement(element); 319 | } else if (matches(element, formSubmitSelector)) { 320 | return enableFormElements(element); 321 | } 322 | }; 323 | const disableElement = e => { 324 | const element = e instanceof Event ? e.target : e; 325 | if (isContentEditable(element)) { 326 | return; 327 | } 328 | if (matches(element, linkDisableSelector)) { 329 | return disableLinkElement(element); 330 | } else if (matches(element, buttonDisableSelector) || matches(element, formDisableSelector)) { 331 | return disableFormElement(element); 332 | } else if (matches(element, formSubmitSelector)) { 333 | return disableFormElements(element); 334 | } 335 | }; 336 | var disableLinkElement = function (element) { 337 | if (getData(element, "ujs:disabled")) { 338 | return; 339 | } 340 | const replacement = element.getAttribute("data-disable-with"); 341 | if (replacement != null) { 342 | setData(element, "ujs:enable-with", element.innerHTML); 343 | element.innerHTML = replacement; 344 | } 345 | element.addEventListener("click", stopEverything); 346 | return setData(element, "ujs:disabled", true); 347 | }; 348 | var enableLinkElement = function (element) { 349 | const originalText = getData(element, "ujs:enable-with"); 350 | if (originalText != null) { 351 | element.innerHTML = originalText; 352 | setData(element, "ujs:enable-with", null); 353 | } 354 | element.removeEventListener("click", stopEverything); 355 | return setData(element, "ujs:disabled", null); 356 | }; 357 | var disableFormElements = form => formElements(form, formDisableSelector).forEach(disableFormElement); 358 | var disableFormElement = function (element) { 359 | if (getData(element, "ujs:disabled")) { 360 | return; 361 | } 362 | const replacement = element.getAttribute("data-disable-with"); 363 | if (replacement != null) { 364 | if (matches(element, "button")) { 365 | setData(element, "ujs:enable-with", element.innerHTML); 366 | element.innerHTML = replacement; 367 | } else { 368 | setData(element, "ujs:enable-with", element.value); 369 | element.value = replacement; 370 | } 371 | } 372 | element.disabled = true; 373 | return setData(element, "ujs:disabled", true); 374 | }; 375 | var enableFormElements = form => formElements(form, formEnableSelector).forEach((element => enableFormElement(element))); 376 | var enableFormElement = function (element) { 377 | const originalText = getData(element, "ujs:enable-with"); 378 | if (originalText != null) { 379 | if (matches(element, "button")) { 380 | element.innerHTML = originalText; 381 | } else { 382 | element.value = originalText; 383 | } 384 | setData(element, "ujs:enable-with", null); 385 | } 386 | element.disabled = false; 387 | return setData(element, "ujs:disabled", null); 388 | }; 389 | var isXhrRedirect = function (event) { 390 | const xhr = event.detail ? event.detail[0] : undefined; 391 | return xhr && xhr.getResponseHeader("X-Xhr-Redirect"); 392 | }; 393 | const handleMethodWithRails = rails => function (e) { 394 | const link = this; 395 | const method = link.getAttribute("data-method"); 396 | if (!method) { 397 | return; 398 | } 399 | if (isContentEditable(this)) { 400 | return; 401 | } 402 | const href = rails.href(link); 403 | const csrfToken$1 = csrfToken(); 404 | const csrfParam$1 = csrfParam(); 405 | const form = document.createElement("form"); 406 | let formContent = ``; 407 | if (csrfParam$1 && csrfToken$1 && !isCrossDomain(href)) { 408 | formContent += ``; 409 | } 410 | formContent += ''; 411 | form.method = "post"; 412 | form.action = href; 413 | form.target = link.target; 414 | form.innerHTML = formContent; 415 | form.style.display = "none"; 416 | document.body.appendChild(form); 417 | form.querySelector('[type="submit"]').click(); 418 | stopEverything(e); 419 | }; 420 | const isRemote = function (element) { 421 | const value = element.getAttribute("data-remote"); 422 | return value != null && value !== "false"; 423 | }; 424 | const handleRemoteWithRails = rails => function (e) { 425 | let data, method, url; 426 | const element = this; 427 | if (!isRemote(element)) { 428 | return true; 429 | } 430 | if (!fire(element, "ajax:before")) { 431 | fire(element, "ajax:stopped"); 432 | return false; 433 | } 434 | if (isContentEditable(element)) { 435 | fire(element, "ajax:stopped"); 436 | return false; 437 | } 438 | const withCredentials = element.getAttribute("data-with-credentials"); 439 | const dataType = element.getAttribute("data-type") || "script"; 440 | if (matches(element, formSubmitSelector)) { 441 | const button = getData(element, "ujs:submit-button"); 442 | method = getData(element, "ujs:submit-button-formmethod") || element.getAttribute("method") || "get"; 443 | url = getData(element, "ujs:submit-button-formaction") || element.getAttribute("action") || location.href; 444 | if (method.toUpperCase() === "GET") { 445 | url = url.replace(/\?.*$/, ""); 446 | } 447 | if (element.enctype === "multipart/form-data") { 448 | data = new FormData(element); 449 | if (button != null) { 450 | data.append(button.name, button.value); 451 | } 452 | } else { 453 | data = serializeElement(element, button); 454 | } 455 | setData(element, "ujs:submit-button", null); 456 | setData(element, "ujs:submit-button-formmethod", null); 457 | setData(element, "ujs:submit-button-formaction", null); 458 | } else if (matches(element, buttonClickSelector) || matches(element, inputChangeSelector)) { 459 | method = element.getAttribute("data-method"); 460 | url = element.getAttribute("data-url"); 461 | data = serializeElement(element, element.getAttribute("data-params")); 462 | } else { 463 | method = element.getAttribute("data-method"); 464 | url = rails.href(element); 465 | data = element.getAttribute("data-params"); 466 | } 467 | ajax({ 468 | type: method || "GET", 469 | url: url, 470 | data: data, 471 | dataType: dataType, 472 | beforeSend(xhr, options) { 473 | if (fire(element, "ajax:beforeSend", [xhr, options])) { 474 | return fire(element, "ajax:send", [xhr]); 475 | } else { 476 | fire(element, "ajax:stopped"); 477 | return false; 478 | } 479 | }, 480 | success(...args) { 481 | return fire(element, "ajax:success", args); 482 | }, 483 | error(...args) { 484 | return fire(element, "ajax:error", args); 485 | }, 486 | complete(...args) { 487 | return fire(element, "ajax:complete", args); 488 | }, 489 | crossDomain: isCrossDomain(url), 490 | withCredentials: withCredentials != null && withCredentials !== "false" 491 | }); 492 | stopEverything(e); 493 | }; 494 | const formSubmitButtonClick = function (e) { 495 | const button = this; 496 | const { form: form } = button; 497 | if (!form) { 498 | return; 499 | } 500 | if (button.name) { 501 | setData(form, "ujs:submit-button", { 502 | name: button.name, 503 | value: button.value 504 | }); 505 | } 506 | setData(form, "ujs:formnovalidate-button", button.formNoValidate); 507 | setData(form, "ujs:submit-button-formaction", button.getAttribute("formaction")); 508 | return setData(form, "ujs:submit-button-formmethod", button.getAttribute("formmethod")); 509 | }; 510 | const preventInsignificantClick = function (e) { 511 | const link = this; 512 | const method = (link.getAttribute("data-method") || "GET").toUpperCase(); 513 | const data = link.getAttribute("data-params"); 514 | const metaClick = e.metaKey || e.ctrlKey; 515 | const insignificantMetaClick = metaClick && method === "GET" && !data; 516 | const nonPrimaryMouseClick = e.button != null && e.button !== 0; 517 | if (nonPrimaryMouseClick || insignificantMetaClick) { 518 | e.stopImmediatePropagation(); 519 | } 520 | }; 521 | const Rails = { 522 | $: $, 523 | ajax: ajax, 524 | buttonClickSelector: buttonClickSelector, 525 | buttonDisableSelector: buttonDisableSelector, 526 | confirm: confirm, 527 | cspNonce: cspNonce, 528 | csrfToken: csrfToken, 529 | csrfParam: csrfParam, 530 | CSRFProtection: CSRFProtection, 531 | delegate: delegate, 532 | disableElement: disableElement, 533 | enableElement: enableElement, 534 | fileInputSelector: fileInputSelector, 535 | fire: fire, 536 | formElements: formElements, 537 | formEnableSelector: formEnableSelector, 538 | formDisableSelector: formDisableSelector, 539 | formInputClickSelector: formInputClickSelector, 540 | formSubmitButtonClick: formSubmitButtonClick, 541 | formSubmitSelector: formSubmitSelector, 542 | getData: getData, 543 | handleDisabledElement: handleDisabledElement, 544 | href: href, 545 | inputChangeSelector: inputChangeSelector, 546 | isCrossDomain: isCrossDomain, 547 | linkClickSelector: linkClickSelector, 548 | linkDisableSelector: linkDisableSelector, 549 | loadCSPNonce: loadCSPNonce, 550 | matches: matches, 551 | preventInsignificantClick: preventInsignificantClick, 552 | refreshCSRFTokens: refreshCSRFTokens, 553 | serializeElement: serializeElement, 554 | setData: setData, 555 | stopEverything: stopEverything 556 | }; 557 | const handleConfirm = handleConfirmWithRails(Rails); 558 | Rails.handleConfirm = handleConfirm; 559 | const handleMethod = handleMethodWithRails(Rails); 560 | Rails.handleMethod = handleMethod; 561 | const handleRemote = handleRemoteWithRails(Rails); 562 | Rails.handleRemote = handleRemote; 563 | const start = function () { 564 | if (window._rails_loaded) { 565 | throw new Error("rails-ujs has already been loaded!"); 566 | } 567 | window.addEventListener("pageshow", (function () { 568 | $(formEnableSelector).forEach((function (el) { 569 | if (getData(el, "ujs:disabled")) { 570 | enableElement(el); 571 | } 572 | })); 573 | $(linkDisableSelector).forEach((function (el) { 574 | if (getData(el, "ujs:disabled")) { 575 | enableElement(el); 576 | } 577 | })); 578 | })); 579 | delegate(document, linkDisableSelector, "ajax:complete", enableElement); 580 | delegate(document, linkDisableSelector, "ajax:stopped", enableElement); 581 | delegate(document, buttonDisableSelector, "ajax:complete", enableElement); 582 | delegate(document, buttonDisableSelector, "ajax:stopped", enableElement); 583 | delegate(document, linkClickSelector, "click", preventInsignificantClick); 584 | delegate(document, linkClickSelector, "click", handleDisabledElement); 585 | delegate(document, linkClickSelector, "click", handleConfirm); 586 | delegate(document, linkClickSelector, "click", disableElement); 587 | delegate(document, linkClickSelector, "click", handleRemote); 588 | delegate(document, linkClickSelector, "click", handleMethod); 589 | delegate(document, buttonClickSelector, "click", preventInsignificantClick); 590 | delegate(document, buttonClickSelector, "click", handleDisabledElement); 591 | delegate(document, buttonClickSelector, "click", handleConfirm); 592 | delegate(document, buttonClickSelector, "click", disableElement); 593 | delegate(document, buttonClickSelector, "click", handleRemote); 594 | delegate(document, inputChangeSelector, "change", handleDisabledElement); 595 | delegate(document, inputChangeSelector, "change", handleConfirm); 596 | delegate(document, inputChangeSelector, "change", handleRemote); 597 | delegate(document, formSubmitSelector, "submit", handleDisabledElement); 598 | delegate(document, formSubmitSelector, "submit", handleConfirm); 599 | delegate(document, formSubmitSelector, "submit", handleRemote); 600 | delegate(document, formSubmitSelector, "submit", (e => setTimeout((() => disableElement(e)), 13))); 601 | delegate(document, formSubmitSelector, "ajax:send", disableElement); 602 | delegate(document, formSubmitSelector, "ajax:complete", enableElement); 603 | delegate(document, formInputClickSelector, "click", preventInsignificantClick); 604 | delegate(document, formInputClickSelector, "click", handleDisabledElement); 605 | delegate(document, formInputClickSelector, "click", handleConfirm); 606 | delegate(document, formInputClickSelector, "click", formSubmitButtonClick); 607 | document.addEventListener("DOMContentLoaded", refreshCSRFTokens); 608 | document.addEventListener("DOMContentLoaded", loadCSPNonce); 609 | return window._rails_loaded = true; 610 | }; 611 | Rails.start = start; 612 | if (typeof jQuery !== "undefined" && jQuery && jQuery.ajax) { 613 | if (jQuery.rails) { 614 | throw new Error("If you load both jquery_ujs and rails-ujs, use rails-ujs only."); 615 | } 616 | jQuery.rails = Rails; 617 | jQuery.ajaxPrefilter((function (options, originalOptions, xhr) { 618 | if (!options.crossDomain) { 619 | return CSRFProtection(xhr); 620 | } 621 | })); 622 | } 623 | if (typeof exports !== "object" && typeof module === "undefined") { 624 | window.Rails = Rails; 625 | if (fire(document, "rails:attachBindings")) { 626 | start(); 627 | } 628 | } 629 | return Rails; 630 | })); -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | RailsPgExtras::Web::Engine.routes.draw do 2 | resources :queries, only: [:index] 3 | 4 | post "/actions/kill_all", to: "actions#kill_all", as: :kill_all_action 5 | post "/actions/pg_stat_statements_reset", to: "actions#pg_stat_statements_reset", as: :pg_stat_statements_reset_action 6 | post "/actions/add_extensions", to: "actions#add_extensions", as: :add_extensions_action 7 | 8 | root to: "queries#index" 9 | end 10 | -------------------------------------------------------------------------------- /docker-compose.yml.sample: -------------------------------------------------------------------------------- 1 | services: 2 | postgres12: 3 | image: postgres:12.20-alpine 4 | command: postgres -c shared_preload_libraries=pg_stat_statements 5 | environment: 6 | POSTGRES_USER: postgres 7 | POSTGRES_DB: rails-pg-extras-test 8 | POSTGRES_PASSWORD: secret 9 | ports: 10 | - '5432:5432' 11 | postgres13: 12 | image: postgres:13.16-alpine 13 | command: postgres -c shared_preload_libraries=pg_stat_statements 14 | environment: 15 | POSTGRES_USER: postgres 16 | POSTGRES_DB: rails-pg-extras-test 17 | POSTGRES_PASSWORD: secret 18 | ports: 19 | - '5433:5432' 20 | postgres14: 21 | image: postgres:14.13-alpine 22 | command: postgres -c shared_preload_libraries=pg_stat_statements 23 | environment: 24 | POSTGRES_USER: postgres 25 | POSTGRES_DB: rails-pg-extras-test 26 | POSTGRES_PASSWORD: secret 27 | ports: 28 | - '5434:5432' 29 | postgres15: 30 | image: postgres:15.8-alpine 31 | command: postgres -c shared_preload_libraries=pg_stat_statements 32 | environment: 33 | POSTGRES_USER: postgres 34 | POSTGRES_DB: rails-pg-extras-test 35 | POSTGRES_PASSWORD: secret 36 | ports: 37 | - '5435:5432' 38 | postgres16: 39 | image: postgres:16.4-alpine 40 | command: postgres -c shared_preload_libraries=pg_stat_statements 41 | environment: 42 | POSTGRES_USER: postgres 43 | POSTGRES_DB: rails-pg-extras-test 44 | POSTGRES_PASSWORD: secret 45 | ports: 46 | - '5436:5432' 47 | postgres17: 48 | image: postgres:17.0-alpine 49 | command: postgres -c shared_preload_libraries=pg_stat_statements 50 | environment: 51 | POSTGRES_USER: postgres 52 | POSTGRES_DB: rails-pg-extras-test 53 | POSTGRES_PASSWORD: secret 54 | ports: 55 | - '5437:5432' 56 | 57 | -------------------------------------------------------------------------------- /lib/rails-pg-extras.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "terminal-table" 4 | require "ruby-pg-extras" 5 | require "rails_pg_extras/diagnose_data" 6 | require "rails_pg_extras/diagnose_print" 7 | require "rails_pg_extras/index_info" 8 | require "rails_pg_extras/index_info_print" 9 | require "rails_pg_extras/missing_fk_indexes" 10 | require "rails_pg_extras/missing_fk_constraints" 11 | require "rails_pg_extras/table_info" 12 | require "rails_pg_extras/table_info_print" 13 | 14 | module RailsPgExtras 15 | @@database_url = nil 16 | QUERIES = RubyPgExtras::QUERIES 17 | DEFAULT_ARGS = RubyPgExtras::DEFAULT_ARGS 18 | NEW_PG_STAT_STATEMENTS = RubyPgExtras::NEW_PG_STAT_STATEMENTS 19 | PG_STAT_STATEMENTS_17 = RubyPgExtras::PG_STAT_STATEMENTS_17 20 | 21 | QUERIES.each do |query_name| 22 | define_singleton_method query_name do |options = {}| 23 | run_query( 24 | query_name: query_name, 25 | in_format: options.fetch(:in_format, :display_table), 26 | args: options.fetch(:args, {}), 27 | ) 28 | end 29 | end 30 | 31 | def self.run_query(query_name:, in_format:, args: {}) 32 | RubyPgExtras.run_query_base( 33 | query_name: query_name, 34 | conn: connection, 35 | exec_method: :execute, 36 | in_format: in_format, 37 | args: args, 38 | ) 39 | end 40 | 41 | def self.diagnose(in_format: :display_table) 42 | data = RailsPgExtras::DiagnoseData.call 43 | 44 | if in_format == :display_table 45 | RailsPgExtras::DiagnosePrint.call(data) 46 | elsif in_format == :hash 47 | data 48 | else 49 | raise "Invalid 'in_format' argument!" 50 | end 51 | end 52 | 53 | def self.measure_duration(&block) 54 | starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 55 | block.call 56 | ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 57 | (ending - starting) * 1000 58 | end 59 | 60 | def self.measure_queries(&block) 61 | queries = {} 62 | sql_duration = 0 63 | 64 | method_name = if ActiveSupport::Notifications.respond_to?(:monotonic_subscribe) 65 | :monotonic_subscribe 66 | else 67 | :subscribe 68 | end 69 | 70 | subscriber = ActiveSupport::Notifications.public_send(method_name, "sql.active_record") do |_name, start, finish, _id, payload| 71 | unless payload[:name] =~ /SCHEMA/ 72 | key = payload[:sql] 73 | queries[key] ||= { count: 0, total_duration: 0, min_duration: nil, max_duration: nil } 74 | queries[key][:count] += 1 75 | duration = (finish - start) * 1000 76 | queries[key][:total_duration] += duration 77 | sql_duration += duration 78 | 79 | if queries[key][:min_duration] == nil || queries[key][:min_duration] > duration 80 | queries[key][:min_duration] = duration.round(2) 81 | end 82 | 83 | if queries[key][:max_duration] == nil || queries[key][:max_duration] < duration 84 | queries[key][:max_duration] = duration.round(2) 85 | end 86 | end 87 | end 88 | 89 | total_duration = measure_duration do 90 | block.call 91 | end 92 | 93 | queries = queries.reduce({}) do |agg, val| 94 | val[1][:avg_duration] = (val[1][:total_duration] / val[1][:count]).round(2) 95 | val[1][:total_duration] = val[1][:total_duration].round(2) 96 | agg.merge(val[0] => val[1]) 97 | end 98 | 99 | ActiveSupport::Notifications.unsubscribe(subscriber) 100 | { 101 | count: queries.reduce(0) { |agg, val| agg + val[1].fetch(:count) }, 102 | queries: queries, 103 | total_duration: total_duration.round(2), 104 | sql_duration: sql_duration.round(2), 105 | } 106 | end 107 | 108 | def self.index_info(args: {}, in_format: :display_table) 109 | data = RailsPgExtras::IndexInfo.call(args[:table_name]) 110 | 111 | if in_format == :display_table 112 | RailsPgExtras::IndexInfoPrint.call(data) 113 | elsif in_format == :hash 114 | data 115 | elsif in_format == :array 116 | data.map(&:values) 117 | else 118 | raise "Invalid 'in_format' argument!" 119 | end 120 | end 121 | 122 | def self.table_info(args: {}, in_format: :display_table) 123 | data = RailsPgExtras::TableInfo.call(args[:table_name]) 124 | 125 | if in_format == :display_table 126 | RailsPgExtras::TableInfoPrint.call(data) 127 | elsif in_format == :hash 128 | data 129 | elsif in_format == :array 130 | data.map(&:values) 131 | else 132 | raise "Invalid 'in_format' argument!" 133 | end 134 | end 135 | 136 | def self.missing_fk_indexes(args: {}, in_format: :display_table) 137 | result = RailsPgExtras::MissingFkIndexes.call(args[:table_name]) 138 | RubyPgExtras.display_result(result, title: "Missing foreign key indexes", in_format: in_format) 139 | end 140 | 141 | def self.missing_fk_constraints(args: {}, in_format: :display_table) 142 | result = RailsPgExtras::MissingFkConstraints.call(args[:table_name]) 143 | RubyPgExtras.display_result(result, title: "Missing foreign key constraints", in_format: in_format) 144 | end 145 | 146 | def self.database_url=(value) 147 | @@database_url = value 148 | end 149 | 150 | def self.connection 151 | db_url = @@database_url || ENV["RAILS_PG_EXTRAS_DATABASE_URL"] 152 | 153 | if db_url.present? 154 | connector = ActiveRecord::Base.establish_connection(db_url) 155 | 156 | if connector.respond_to?(:connection) 157 | connector.connection 158 | elsif connector.respond_to?(:lease_connection) 159 | connector.lease_connection 160 | else 161 | raise "Unsupported connector: #{connector.class}" 162 | end 163 | else 164 | ActiveRecord::Base.connection 165 | end 166 | end 167 | end 168 | 169 | require "rails_pg_extras/web" 170 | require "rails_pg_extras/configuration" 171 | require "rails_pg_extras/railtie" if defined?(Rails) 172 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_pg_extras/web" 4 | 5 | module RailsPgExtras 6 | class Configuration 7 | DEFAULT_CONFIG = { enabled_web_actions: Web::ACTIONS - [:kill_all, :kill_pid], public_dashboard: ENV["RAILS_PG_EXTRAS_PUBLIC_DASHBOARD"] == "true" } 8 | 9 | attr_reader :enabled_web_actions 10 | attr_accessor :public_dashboard 11 | 12 | def initialize(attrs) 13 | self.enabled_web_actions = attrs[:enabled_web_actions] 14 | self.public_dashboard = attrs[:public_dashboard] 15 | end 16 | 17 | def enabled_web_actions=(*actions) 18 | @enabled_web_actions = actions.flatten.map(&:to_sym) 19 | end 20 | end 21 | 22 | def self.configuration 23 | @configuration ||= Configuration.new(Configuration::DEFAULT_CONFIG) 24 | end 25 | 26 | def self.configure 27 | yield(configuration) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/diagnose_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class DiagnoseData < ::RubyPgExtras::DiagnoseData 5 | private 6 | 7 | def query_module 8 | RailsPgExtras 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/diagnose_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class DiagnosePrint < ::RubyPgExtras::DiagnosePrint 5 | private 6 | 7 | def title 8 | "rails-pg-extras - diagnose report" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/index_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class IndexInfo < RubyPgExtras::IndexInfo 5 | private 6 | 7 | def query_module 8 | RailsPgExtras 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/index_info_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class IndexInfoPrint < RubyPgExtras::IndexInfoPrint 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/mcp_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fast_mcp" 4 | require "rack" 5 | require "rails_pg_extras/version" 6 | 7 | SKIP_QUERIES = %i[ 8 | add_extensions 9 | pg_stat_statements_reset 10 | kill_pid 11 | kill_all 12 | mandelbrot 13 | ] 14 | 15 | QUERY_TOOL_CLASSES = RubyPgExtras::QUERIES.reject { |q| SKIP_QUERIES.include?(q) }.map do |query_name| 16 | Class.new(FastMcp::Tool) do 17 | description RubyPgExtras.description_for(query_name: query_name) 18 | 19 | define_method :call do 20 | RailsPgExtras.public_send(query_name, in_format: :hash) 21 | end 22 | 23 | define_singleton_method :name do 24 | query_name.to_s 25 | end 26 | end 27 | end 28 | 29 | class MissingFkConstraintsTool < FastMcp::Tool 30 | description "Shows missing foreign key constraints" 31 | 32 | def call 33 | RailsPgExtras.missing_fk_constraints(in_format: :hash) 34 | end 35 | 36 | def self.name 37 | "missing_fk_constraints" 38 | end 39 | end 40 | 41 | class MissingFkIndexesTool < FastMcp::Tool 42 | description "Shows missing foreign key indexes" 43 | 44 | def call 45 | RailsPgExtras.missing_fk_indexes(in_format: :hash) 46 | end 47 | 48 | def self.name 49 | "missing_fk_indexes" 50 | end 51 | end 52 | 53 | class DiagnoseTool < FastMcp::Tool 54 | description "Performs a health check of the database" 55 | 56 | def call 57 | RailsPgExtras.diagnose(in_format: :hash) 58 | end 59 | 60 | def self.name 61 | "diagnose" 62 | end 63 | end 64 | 65 | class ReadmeResource < FastMcp::Resource 66 | uri "file://README.md" 67 | resource_name "README" 68 | description "The README for RailsPgExtras" 69 | mime_type "text/plain" 70 | 71 | def content 72 | File.read(uri) 73 | end 74 | end 75 | 76 | module RailsPgExtras 77 | class MCPApp 78 | def self.build 79 | app = lambda do |_env| 80 | [200, { "Content-Type" => "text/html" }, 81 | ["

Hello from Rack!

This is a simple Rack app with MCP middleware.

"]] 82 | end 83 | 84 | # Create the MCP middleware 85 | mcp_app = FastMcp.rack_middleware( 86 | app, 87 | name: "rails-pg-extras", version: RailsPgExtras::VERSION, 88 | path_prefix: "/pg-extras-mcp", 89 | logger: Logger.new($stdout), 90 | ) do |server| 91 | server.register_tools(DiagnoseTool) 92 | server.register_tools(MissingFkConstraintsTool) 93 | server.register_tools(MissingFkIndexesTool) 94 | server.register_tools(*QUERY_TOOL_CLASSES) 95 | 96 | server.register_resource(ReadmeResource) 97 | 98 | Rack::Builder.new { run mcp_app } 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/missing_fk_constraints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class MissingFkConstraints < RubyPgExtras::MissingFkConstraints 5 | private 6 | 7 | def query_module 8 | RailsPgExtras 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/missing_fk_indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class MissingFkIndexes < RubyPgExtras::MissingFkIndexes 5 | private 6 | 7 | def query_module 8 | RailsPgExtras 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RailsPgExtras::Railtie < Rails::Railtie 4 | rake_tasks do 5 | load "rails_pg_extras/tasks/all.rake" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/table_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class TableInfo < RubyPgExtras::TableInfo 5 | private 6 | 7 | def query_module 8 | RailsPgExtras 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/table_info_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | class TableInfoPrint < RubyPgExtras::TableInfoPrint 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/tasks/all.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails-pg-extras" 4 | 5 | namespace :pg_extras do 6 | RailsPgExtras::QUERIES.each do |query_name| 7 | desc RubyPgExtras.description_for(query_name: query_name) 8 | task query_name.to_sym => :environment do 9 | RailsPgExtras.public_send(query_name) 10 | end 11 | end 12 | 13 | desc "Generate a PostgreSQL healthcheck report" 14 | task diagnose: :environment do 15 | RailsPgExtras.diagnose 16 | end 17 | 18 | desc "Display tables metadata metrics" 19 | task table_info: :environment do 20 | RailsPgExtras.table_info 21 | end 22 | 23 | desc "Display indexes metadata metrics" 24 | task index_info: :environment do 25 | RailsPgExtras.index_info 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsPgExtras 4 | VERSION = "5.6.13" 5 | end 6 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/web.rb: -------------------------------------------------------------------------------- 1 | require "rails_pg_extras/web/engine" 2 | 3 | module RailsPgExtras 4 | module Web 5 | ACTIONS = %i[kill_all pg_stat_statements_reset add_extensions kill_pid].freeze 6 | 7 | def self.action_enabled?(action_name) 8 | RailsPgExtras.configuration.enabled_web_actions.include?(action_name.to_sym) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_pg_extras/web/engine.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | 3 | module RailsPgExtras::Web 4 | class Engine < ::Rails::Engine 5 | isolate_namespace RailsPgExtras::Web 6 | config.middleware.use ActionDispatch::Flash 7 | initializer "static assets" do |app| 8 | app.middleware.insert_before(0, ::ActionDispatch::Static, "#{RailsPgExtras::Web::Engine.root}/assets") 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /marginalia-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/4b654be1f32cd149ac1184eec657c7a7d0a475cc/marginalia-logs.png -------------------------------------------------------------------------------- /pg-extras-mcp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/4b654be1f32cd149ac1184eec657c7a7d0a475cc/pg-extras-mcp.png -------------------------------------------------------------------------------- /pg-extras-ui-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/4b654be1f32cd149ac1184eec657c7a7d0a475cc/pg-extras-ui-3.png -------------------------------------------------------------------------------- /rails-pg-extras-diagnose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/4b654be1f32cd149ac1184eec657c7a7d0a475cc/rails-pg-extras-diagnose.png -------------------------------------------------------------------------------- /rails-pg-extras.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "rails_pg_extras/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "rails-pg-extras" 8 | s.version = RailsPgExtras::VERSION 9 | s.authors = ["pawurb"] 10 | s.email = ["contact@pawelurbanek.com"] 11 | s.summary = %q{ Rails PostgreSQL performance database insights } 12 | s.description = %q{ Rails port of Heroku PG Extras. The goal of this project is to provide a powerful insights into PostgreSQL database for Ruby on Rails apps that are not using the default Heroku PostgreSQL plugin. } 13 | s.homepage = "http://github.com/pawurb/rails-pg-extras" 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = s.files.grep(%r{^(spec)/}) 16 | s.require_paths = ["lib"] 17 | s.license = "MIT" 18 | s.add_dependency "ruby-pg-extras", RailsPgExtras::VERSION 19 | s.add_dependency "railties" 20 | s.add_dependency "actionpack" 21 | s.add_dependency "activerecord" 22 | s.add_development_dependency "rake" 23 | s.add_development_dependency "rspec" 24 | s.add_development_dependency "rufo" 25 | 26 | if s.respond_to?(:metadata=) 27 | s.metadata = { "rubygems_mfa_required" => "true" } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/smoke_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "rails-pg-extras" 5 | 6 | describe RailsPgExtras do 7 | SKIP_QUERIES = %i[ 8 | kill_all 9 | table_schema 10 | table_foreign_keys 11 | ] 12 | 13 | RailsPgExtras::QUERIES.reject { |q| SKIP_QUERIES.include?(q) }.each do |query_name| 14 | it "#{query_name} query can be executed" do 15 | expect do 16 | RailsPgExtras.run_query( 17 | query_name: query_name, 18 | in_format: :hash, 19 | ) 20 | end.not_to raise_error 21 | end 22 | end 23 | 24 | it "runs the custom methods" do 25 | expect do 26 | RailsPgExtras.diagnose(in_format: :hash) 27 | end.not_to raise_error 28 | 29 | expect do 30 | RailsPgExtras.index_info(in_format: :hash) 31 | end.not_to raise_error 32 | 33 | expect do 34 | RailsPgExtras.table_info(in_format: :hash) 35 | end.not_to raise_error 36 | end 37 | 38 | it "collecting queries data works" do 39 | output = RailsPgExtras.measure_queries { RailsPgExtras.diagnose(in_format: :hash) } 40 | expect(output.fetch(:count) > 0).to eq(true) 41 | end 42 | 43 | it "supports custom RAILS_PG_EXTRAS_DATABASE_URL" do 44 | ENV["RAILS_PG_EXTRAS_DATABASE_URL"] = ENV["DATABASE_URL"] 45 | puts ENV["RAILS_PG_EXTRAS_DATABASE_URL"] 46 | 47 | expect do 48 | RailsPgExtras.calls 49 | end.not_to raise_error 50 | 51 | ENV["RAILS_PG_EXTRAS_DATABASE_URL"] = nil 52 | end 53 | 54 | describe "missing_fk_indexes" do 55 | it "works" do 56 | expect { 57 | RailsPgExtras.missing_fk_indexes 58 | }.not_to raise_error 59 | end 60 | end 61 | 62 | describe "missing_fk_constraints" do 63 | it "works" do 64 | expect { 65 | RailsPgExtras.missing_fk_constraints 66 | }.not_to raise_error 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | require "active_record" 6 | require_relative "../lib/rails-pg-extras" 7 | 8 | pg_version = ENV["PG_VERSION"] 9 | 10 | PG_PORTS = { 11 | "12" => "5432", 12 | "13" => "5433", 13 | "14" => "5434", 14 | "15" => "5435", 15 | "16" => "5436", 16 | "17" => "5437", 17 | } 18 | 19 | port = PG_PORTS.fetch(pg_version, "5432") 20 | 21 | ENV["DATABASE_URL"] ||= "postgresql://postgres:secret@localhost:#{port}/rails-pg-extras-test" 22 | 23 | RSpec.configure do |config| 24 | config.before :suite do 25 | ActiveRecord::Base.establish_connection( 26 | ENV.fetch("DATABASE_URL") 27 | ) 28 | RailsPgExtras.connection.execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements;") 29 | RailsPgExtras.connection.execute("CREATE EXTENSION IF NOT EXISTS pg_buffercache;") 30 | RailsPgExtras.connection.execute("CREATE EXTENSION IF NOT EXISTS sslinfo;") 31 | end 32 | 33 | config.after :suite do 34 | ActiveRecord::Base.remove_connection 35 | end 36 | end 37 | --------------------------------------------------------------------------------