├── .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 │ ├── 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-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 | Alternative versions: 16 | 17 | - Core dependency - [Ruby](https://github.com/pawurb/ruby-pg-extras) 18 | 19 | - [Rust](https://github.com/pawurb/rust-pg-extras) 20 | 21 | - [NodeJS](https://github.com/pawurb/node-postgres-extras) 22 | 23 | - [Elixir](https://github.com/pawurb/ecto_psql_extras) 24 | 25 | - [Python Flask](https://github.com/nickjj/flask-pg-extras) 26 | 27 | - [Haskell](https://github.com/pawurb/haskell-pg-extras) 28 | 29 | ## Installation 30 | 31 | In your Gemfile 32 | 33 | ```ruby 34 | gem "rails-pg-extras" 35 | ``` 36 | 37 | `calls` and `outliers` queries require [pg_stat_statements](https://www.postgresql.org/docs/current/pgstatstatements.html) extension. 38 | 39 | You can check if it is enabled in your database by running: 40 | 41 | ```ruby 42 | RailsPgExtras.extensions 43 | ``` 44 | You should see the similar line in the output: 45 | 46 | ```bash 47 | | pg_stat_statements | 1.7 | 1.7 | track execution statistics of all SQL statements executed | 48 | ``` 49 | 50 | `ssl_used` requires `sslinfo` extension, and `buffercache_usage`/`buffercache_usage` queries need `pg_buffercache`. You can enable them all by running: 51 | 52 | ```ruby 53 | RailsPgExtras.add_extensions 54 | ``` 55 | 56 | 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: 57 | 58 | ```ruby 59 | ENV["RAILS_PG_EXTRAS_DATABASE_URL"] = "postgresql://postgres:secret@localhost:5432/database_name" 60 | ``` 61 | 62 | ## Usage 63 | 64 | Each command can be used as a rake task, or a directly from the Ruby code. 65 | 66 | ```bash 67 | rake pg_extras:cache_hit 68 | ``` 69 | 70 | ```ruby 71 | RailsPgExtras.cache_hit 72 | ``` 73 | ```bash 74 | +----------------+------------------------+ 75 | | Index and table hit rate | 76 | +----------------+------------------------+ 77 | | name | ratio | 78 | +----------------+------------------------+ 79 | | index hit rate | 0.97796610169491525424 | 80 | | table hit rate | 0.96724294813466787989 | 81 | +----------------+------------------------+ 82 | ``` 83 | 84 | 85 | 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): 86 | 87 | ```ruby 88 | RailsPgExtras.cache_hit(in_format: :hash) => 89 | 90 | [{"name"=>"index hit rate", "ratio"=>"0.97796610169491525424"}, {"name"=>"table hit rate", "ratio"=>"0.96724294813466787989"}] 91 | 92 | RailsPgExtras.cache_hit(in_format: :array) => 93 | 94 | [["index hit rate", "0.97796610169491525424"], ["table hit rate", "0.96724294813466787989"]] 95 | 96 | RailsPgExtras.cache_hit(in_format: :raw) => 97 | 98 | # 99 | ``` 100 | 101 | Some methods accept an optional `args` param allowing you to customize queries: 102 | 103 | ```ruby 104 | RailsPgExtras.long_running_queries(args: { threshold: "200 milliseconds" }) 105 | 106 | ``` 107 | 108 | By default, queries target the `public` schema of the database. You can specify a different schema by passing the `schema` argument: 109 | 110 | ```ruby 111 | RailsPgExtras.table_cache_hit(args: { schema: "my_schema" }) 112 | ``` 113 | 114 | You can customize the default `public` schema by setting `ENV['PG_EXTRAS_SCHEMA']` value. 115 | 116 | ## Diagnose report 117 | 118 | 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: 119 | 120 | ```ruby 121 | RailsPgExtras.diagnose 122 | 123 | $ rake pg_extras:diagnose 124 | ``` 125 | 126 | ![Diagnose report](https://github.com/pawurb/rails-pg-extras/raw/main/rails-pg-extras-diagnose.png) 127 | 128 | Keep reading to learn about methods that `diagnose` uses under the hood. 129 | 130 | ## Visual interface 131 | 132 | You can enable UI using a Rails engine by adding the following code in `config/routes.rb`: 133 | 134 | ```ruby 135 | mount RailsPgExtras::Web::Engine, at: 'pg_extras' 136 | ``` 137 | 138 | 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`. 139 | 140 | You can configure available web actions in `config/initializers/rails_pg_extras.rb`: 141 | 142 | ```ruby 143 | RailsPgExtras.configure do |config| 144 | # Rails-pg-extras does not enable all the web actions by default. You can check all available actions via `RailsPgExtras::Web::ACTIONS`. 145 | # For example, you may want to enable the dangerous `kill_all` action. 146 | 147 | config.enabled_web_actions = %i[kill_all pg_stat_statements_reset add_extensions] 148 | end 149 | ``` 150 | 151 | ## Available methods 152 | 153 | ### `measure_queries` 154 | 155 | 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: 156 | 157 | ```ruby 158 | 159 | RailsPgExtras.measure_queries { User.limit(10).map(&:team) } 160 | 161 | {:count=>11, 162 | :queries=> 163 | {"SELECT \"users\".* FROM \"users\" LIMIT $1"=> 164 | {:count=>1, 165 | :total_duration=>1.9, 166 | :min_duration=>1.9, 167 | :max_duration=>1.9, 168 | :avg_duration=>1.9}, 169 | "SELECT \"teams\".* FROM \"teams\" WHERE \"teams\".\"id\" = $1 LIMIT $2"=> 170 | {:count=>10, 171 | :total_duration=>0.94, 172 | :min_duration=>0.62, 173 | :max_duration=>1.37, 174 | :avg_duration=>0.94}}, 175 | :total_duration=>13.35, 176 | :sql_duration=>11.34} 177 | 178 | RailsPgExtras.measure_queries { User.limit(10).includes(:team).map(&:team) } 179 | 180 | {:count=>2, 181 | :queries=> 182 | {"SELECT \"users\".* FROM \"users\" LIMIT $1"=> 183 | {:count=>1, 184 | :total_duration=>3.43, 185 | :min_duration=>3.43, 186 | :max_duration=>3.43, 187 | :avg_duration=>3.43}, 188 | "SELECT \"teams\".* FROM \"teams\" WHERE \"teams\".\"id\" IN ($1, $2, $3, $4, $5, $6, $7, $8)"=> 189 | {:count=>1, 190 | :total_duration=>2.59, 191 | :min_duration=>2.59, 192 | :max_duration=>2.59, 193 | :avg_duration=>2.59}}, 194 | :total_duration=>9.75, 195 | :sql_duration=>6.02} 196 | 197 | ``` 198 | 199 | Optionally, by including [Marginalia gem](https://github.com/basecamp/marginalia) and configuring it to display query backtraces: 200 | 201 | `config/development.rb` 202 | 203 | ```ruby 204 | 205 | Marginalia::Comment.components = [:line] 206 | 207 | ``` 208 | 209 | you can add this info to the output: 210 | 211 | ![Marginalia logs](https://github.com/pawurb/rails-pg-extras/raw/main/marginalia-logs.png) 212 | 213 | ### `missing_fk_indexes` 214 | 215 | 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. 216 | 217 | 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. 218 | 219 | ```ruby 220 | RailsPgExtras.missing_fk_indexes(args: { table_name: "users" }) 221 | 222 | +---------------------------------+ 223 | | Missing foreign key indexes | 224 | +-------------------+-------------+ 225 | | table | column_name | 226 | +-------------------+-------------+ 227 | | feedbacks | team_id | 228 | | votes | user_id | 229 | +-------------------+-------------+ 230 | 231 | ``` 232 | 233 | `table_name` argument is optional, if omitted, the method will display missing fk indexes for all the tables. 234 | 235 | ## `missing_fk_constraints` 236 | 237 | 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). 238 | 239 | ```ruby 240 | RailsPgExtras.missing_fk_constraints(args: { table_name: "users" }) 241 | 242 | +---------------------------------+ 243 | | Missing foreign key constraints | 244 | +-------------------+-------------+ 245 | | table | column_name | 246 | +-------------------+-------------+ 247 | | feedbacks | team_id | 248 | | votes | user_id | 249 | +-------------------+-------------+ 250 | 251 | ``` 252 | 253 | `table_name` argument is optional, if omitted, method will display missing fk constraints for all the tables. 254 | 255 | ### `table_schema` 256 | 257 | This method displays structure of a selected table, listing its column names, together with types, null constraints, and default values. 258 | 259 | ```ruby 260 | RailsPgExtras.table_schema(args: { table_name: "users" }) 261 | 262 | +-----------------------------+-----------------------------+-------------+-----------------------------------+ 263 | | column_name | data_type | is_nullable | column_default | 264 | +-----------------------------+-----------------------------+-------------+-----------------------------------+ 265 | | id | bigint | NO | nextval('users_id_seq'::regclass) | 266 | | team_id | integer | NO | | 267 | | slack_id | character varying | NO | | 268 | | pseudonym | character varying | YES | | 269 | 270 | ``` 271 | 272 | ### `table_info` 273 | 274 | 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. 275 | 276 | ```ruby 277 | RailsPgExtras.table_info(args: { table_name: "users" }) 278 | 279 | | Table name | Table size | Table cache hit | Indexes cache hit | Estimated rows | Sequential scans | Indexes scans | 280 | +------------+------------+-------------------+--------------------+----------------+------------------+---------------+ 281 | | users | 2432 kB | 0.999966685701511 | 0.9988780464661853 | 16650 | 2128 | 512496 | 282 | 283 | ``` 284 | 285 | ### `index_info` 286 | 287 | 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. 288 | 289 | ```ruby 290 | 291 | RailsPgExtras.index_info(args: { table_name: "users" }) 292 | 293 | | Index name | Table name | Columns | Index size | Index scans | Null frac | 294 | +-------------------------------+------------+----------------+------------+-------------+-----------+ 295 | | users_pkey | users | id | 1152 kB | 163007 | 0.00% | 296 | | index_users_on_slack_id | users | slack_id | 1080 kB | 258870 | 0.00% | 297 | | index_users_on_team_id | users | team_id | 816 kB | 70962 | 0.00% | 298 | | index_users_on_uuid | users | uuid | 1032 kB | 0 | 0.00% | 299 | | index_users_on_block_uuid | users | block_uuid | 776 kB | 19502 | 100.00% | 300 | | index_users_on_api_auth_token | users | api_auth_token | 1744 kB | 156 | 0.00% | 301 | 302 | ``` 303 | 304 | ### `cache_hit` 305 | 306 | ```ruby 307 | RailsPgExtras.cache_hit 308 | 309 | $ rake pg_extras:cache_hit 310 | 311 | name | ratio 312 | ----------------+------------------------ 313 | index hit rate | 0.99957765013541945832 314 | table hit rate | 1.00 315 | (2 rows) 316 | ``` 317 | 318 | 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. 319 | 320 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 321 | 322 | ### `index_cache_hit` 323 | 324 | ```ruby 325 | 326 | RailsPgExtras.index_cache_hit 327 | 328 | $ rake pg_extras:index_cache_hit 329 | 330 | | name | buffer_hits | block_reads | total_read | ratio | 331 | +-----------------------+-------------+-------------+------------+-------------------+ 332 | | teams | 187665 | 109 | 187774 | 0.999419514948821 | 333 | | subscriptions | 5160 | 6 | 5166 | 0.99883855981417 | 334 | | plans | 5718 | 9 | 5727 | 0.998428496595076 | 335 | (truncated results for brevity) 336 | ``` 337 | 338 | The same as `cache_hit` with each table's indexes cache hit info displayed separately. 339 | 340 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 341 | 342 | ### `table_cache_hit` 343 | 344 | ```ruby 345 | 346 | RailsPgExtras.table_cache_hit 347 | 348 | $ rake pg_extras:table_cache_hit 349 | 350 | | name | buffer_hits | block_reads | total_read | ratio | 351 | +-----------------------+-------------+-------------+------------+-------------------+ 352 | | plans | 32123 | 2 | 32125 | 0.999937743190662 | 353 | | subscriptions | 95021 | 8 | 95029 | 0.999915815172211 | 354 | | teams | 171637 | 200 | 171837 | 0.99883610631005 | 355 | (truncated results for brevity) 356 | ``` 357 | 358 | The same as `cache_hit` with each table's cache hit info displayed seperately. 359 | 360 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 361 | 362 | ### `db_settings` 363 | 364 | ```ruby 365 | 366 | RailsPgExtras.db_settings 367 | 368 | $ rake pg_extras:db_settings 369 | 370 | name | setting | unit | 371 | ------------------------------+---------+------+ 372 | checkpoint_completion_target | 0.7 | | 373 | default_statistics_target | 100 | | 374 | effective_cache_size | 1350000 | 8kB | 375 | effective_io_concurrency | 1 | | 376 | (truncated results for brevity) 377 | 378 | ``` 379 | 380 | 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. 381 | 382 | [More info](https://pawelurbanek.com/postgresql-fix-performance#cache-hit) 383 | 384 | ### `ssl_used` 385 | 386 | ```ruby 387 | 388 | RailsPgExtras.ssl_used 389 | 390 | | ssl_is_used | 391 | +---------------------------------+ 392 | | t | 393 | 394 | ``` 395 | 396 | Returns boolean indicating if an encrypted SSL is currently used. Connecting to the database via an unencrypted connection is a critical security risk. 397 | 398 | ### `index_usage` 399 | 400 | ```ruby 401 | RailsPgExtras.index_usage 402 | 403 | $ rake pg_extras:index_usage 404 | 405 | relname | percent_of_times_index_used | rows_in_table 406 | ---------------------+-----------------------------+--------------- 407 | events | 65 | 1217347 408 | app_infos | 74 | 314057 409 | app_infos_user_info | 0 | 198848 410 | user_info | 5 | 94545 411 | delayed_jobs | 27 | 0 412 | (5 rows) 413 | ``` 414 | 415 | 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. 416 | 417 | ### `locks` 418 | 419 | ```ruby 420 | RailsPgExtras.locks(args: { limit: 20 }) 421 | 422 | $ rake pg_extras:locks 423 | 424 | procpid | relname | transactionid | granted | query_snippet | mode | age | application | 425 | ---------+---------+---------------+---------+-----------------------+------------------------------------------------------ 426 | 31776 | | | t | in transaction | ExclusiveLock | 00:19:29.837898 | bin/rails 427 | 31776 | | 1294 | t | in transaction | RowExclusiveLock | 00:19:29.837898 | bin/rails 428 | 31912 | | | t | select * from hello; | ExclusiveLock | 00:19:17.94259 | bin/rails 429 | 3443 | | | t | +| ExclusiveLock | 00:00:00 | bin/sidekiq 430 | | | | | select +| | | 431 | | | | | pg_stat_activi | | | 432 | (4 rows) 433 | ``` 434 | 435 | 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. 436 | 437 | [More info](https://pawelurbanek.com/postgresql-fix-performance#deadlocks) 438 | 439 | ### `all_locks` 440 | 441 | ```ruby 442 | RailsPgExtras.all_locks 443 | 444 | $ rake pg_extras:all_locks 445 | ``` 446 | 447 | This command displays all the current locks, regardless of their type. 448 | 449 | ### `outliers` 450 | 451 | ```ruby 452 | RailsPgExtras.outliers(args: { limit: 20 }) 453 | 454 | $ rake pg_extras:outliers 455 | 456 | qry | exec_time | prop_exec_time | ncalls | sync_io_time 457 | -----------------------------------------+------------------+----------------+-------------+-------------- 458 | SELECT * FROM archivable_usage_events.. | 154:39:26.431466 | 72.2% | 34,211,877 | 00:00:00 459 | COPY public.archivable_usage_events (.. | 50:38:33.198418 | 23.6% | 13 | 13:34:21.00108 460 | COPY public.usage_events (id, reporte.. | 02:32:16.335233 | 1.2% | 13 | 00:34:19.784318 461 | INSERT INTO usage_events (id, retaine.. | 01:42:59.436532 | 0.8% | 12,328,187 | 00:00:00 462 | SELECT * FROM usage_events WHERE (alp.. | 01:18:10.754354 | 0.6% | 102,114,301 | 00:00:00 463 | UPDATE usage_events SET reporter_id =.. | 00:52:35.683254 | 0.4% | 23,786,348 | 00:00:00 464 | (truncated results for brevity) 465 | ``` 466 | 467 | 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, and the amount of time that statement spent on synchronous I/O (reading/writing from the file system). 468 | 469 | 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. 470 | 471 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 472 | 473 | ### `calls` 474 | 475 | ```ruby 476 | RailsPgExtras.calls(args: { limit: 10 }) 477 | 478 | $ rake pg_extras:calls 479 | 480 | qry | exec_time | prop_exec_time | ncalls | sync_io_time 481 | -----------------------------------------+------------------+----------------+-------------+-------------- 482 | SELECT * FROM usage_events WHERE (alp.. | 01:18:11.073333 | 0.6% | 102,120,780 | 00:00:00 483 | BEGIN | 00:00:51.285988 | 0.0% | 47,288,662 | 00:00:00 484 | COMMIT | 00:00:52.31724 | 0.0% | 47,288,615 | 00:00:00 485 | SELECT * FROM archivable_usage_event.. | 154:39:26.431466 | 72.2% | 34,211,877 | 00:00:00 486 | UPDATE usage_events SET reporter_id =.. | 00:52:35.986167 | 0.4% | 23,788,388 | 00:00:00 487 | (truncated results for brevity) 488 | ``` 489 | 490 | This command is much like `pg:outliers`, but ordered by the number of times a statement has been called. 491 | 492 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 493 | 494 | ### `blocking` 495 | 496 | ```ruby 497 | RailsPgExtras.blocking 498 | 499 | $ rake pg_extras:blocking 500 | 501 | blocked_pid | blocking_statement | blocking_duration | blocking_pid | blocked_statement | blocked_duration 502 | -------------+--------------------------+-------------------+--------------+------------------------------------------------------------------------------------+------------------ 503 | 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 504 | (1 row) 505 | ``` 506 | 507 | 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. 508 | 509 | [More info](https://pawelurbanek.com/postgresql-fix-performance#deadlocks) 510 | 511 | ### `total_index_size` 512 | 513 | ```ruby 514 | RailsPgExtras.total_index_size 515 | 516 | $ rake pg_extras:total_index_size 517 | 518 | size 519 | ------- 520 | 28194 MB 521 | (1 row) 522 | ``` 523 | 524 | 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). 525 | 526 | ### `index_size` 527 | 528 | ```ruby 529 | RailsPgExtras.index_size 530 | 531 | $ rake pg_extras:index_size 532 | name | size | schema | 533 | ---------------------------------------------------------------+------------------- 534 | idx_activity_attemptable_and_type_lesson_enrollment | 5196 MB | public | 535 | index_enrollment_attemptables_by_attempt_and_last_in_group | 4045 MB | public | 536 | index_attempts_on_student_id | 2611 MB | public | 537 | enrollment_activity_attemptables_pkey | 2513 MB | custom | 538 | index_attempts_on_student_id_final_attemptable_type | 2466 MB | custom | 539 | attempts_pkey | 2466 MB | custom | 540 | index_attempts_on_response_id | 2404 MB | public | 541 | index_attempts_on_enrollment_id | 1957 MB | public | 542 | index_enrollment_attemptables_by_enrollment_activity_id | 1789 MB | public | 543 | enrollment_activities_pkey | 458 MB | public | 544 | (truncated results for brevity) 545 | ``` 546 | 547 | 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). 548 | 549 | ### `table_size` 550 | 551 | ```ruby 552 | RailsPgExtras.table_size 553 | 554 | $ rake pg_extras:table_size 555 | 556 | name | size | schema | 557 | ---------------------------------------------------------------+------------------- 558 | learning_coaches | 196 MB | public | 559 | states | 145 MB | public | 560 | grade_levels | 111 MB | custom | 561 | charities_customers | 73 MB | custom | 562 | charities | 66 MB | public | 563 | (truncated results for brevity) 564 | ``` 565 | 566 | 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. 567 | 568 | ### `table_indexes_size` 569 | 570 | ```ruby 571 | RailsPgExtras.table_indexes_size 572 | 573 | $ rake pg_extras:table_indexes_size 574 | 575 | table | indexes_size 576 | ---------------------------------------------------------------+-------------- 577 | learning_coaches | 153 MB 578 | states | 125 MB 579 | charities_customers | 93 MB 580 | charities | 16 MB 581 | grade_levels | 11 MB 582 | (truncated results for brevity) 583 | ``` 584 | 585 | 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()`. 586 | 587 | ### `total_table_size` 588 | 589 | ```ruby 590 | RailsPgExtras.total_table_size 591 | 592 | $ rake pg_extras:total_table_size 593 | 594 | name | size 595 | ---------------------------------------------------------------+--------- 596 | learning_coaches | 349 MB 597 | states | 270 MB 598 | charities_customers | 166 MB 599 | grade_levels | 122 MB 600 | charities | 82 MB 601 | (truncated results for brevity) 602 | ``` 603 | 604 | 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. 605 | 606 | ### `unused_indexes` 607 | 608 | ```ruby 609 | RailsPgExtras.unused_indexes(args: { max_scans: 50 }) 610 | 611 | $ rake pg_extras:unused_indexes 612 | 613 | table | index | index_size | index_scans 614 | ---------------------+--------------------------------------------+------------+------------- 615 | public.grade_levels | index_placement_attempts_on_grade_level_id | 97 MB | 0 616 | public.observations | observations_attrs_grade_resources | 33 MB | 0 617 | public.messages | user_resource_id_idx | 12 MB | 0 618 | (3 rows) 619 | ``` 620 | 621 | 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. 622 | 623 | [More info](https://pawelurbanek.com/postgresql-fix-performance#unused-indexes) 624 | 625 | ### `duplicate_indexes` 626 | 627 | ```ruby 628 | 629 | RailsPgExtras.duplicate_indexes 630 | 631 | | size | idx1 | idx2 | idx3 | idx4 | 632 | +------------+--------------+----------------+----------+-----------+ 633 | | 128 k | users_pkey | index_users_id | | | 634 | ``` 635 | 636 | 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. 637 | 638 | ### `null_indexes` 639 | 640 | ```ruby 641 | 642 | RailsPgExtras.null_indexes(args: { min_relation_size_mb: 10 }) 643 | 644 | $ rake pg_extras:null_indexes 645 | 646 | oid | index | index_size | unique | indexed_column | null_frac | expected_saving 647 | ---------+--------------------+------------+--------+----------------+-----------+----------------- 648 | 183764 | users_reset_token | 1445 MB | t | reset_token | 97.00% | 1401 MB 649 | 88732 | plan_cancelled_at | 539 MB | f | cancelled_at | 8.30% | 44 MB 650 | 9827345 | users_email | 18 MB | t | email | 28.67% | 5160 kB 651 | 652 | ``` 653 | 654 | 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. 655 | 656 | [More info](https://pawelurbanek.com/postgresql-fix-performance#null-indexes) 657 | 658 | ### `seq_scans` 659 | 660 | ```ruby 661 | RailsPgExtras.seq_scans 662 | 663 | $ rake pg_extras:seq_scans 664 | 665 | name | count 666 | -----------------------------------+---------- 667 | learning_coaches | 44820063 668 | states | 36794975 669 | grade_levels | 13972293 670 | charities_customers | 8615277 671 | charities | 4316276 672 | messages | 3922247 673 | contests_customers | 2915972 674 | classroom_goals | 2142014 675 | (truncated results for brevity) 676 | ``` 677 | 678 | 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. 679 | 680 | [More info](https://pawelurbanek.com/postgresql-fix-performance#missing-indexes) 681 | 682 | ### `long_running_queries` 683 | 684 | ```ruby 685 | RailsPgExtras.long_running_queries(args: { threshold: "200 milliseconds" }) 686 | 687 | $ rake pg_extras:long_running_queries 688 | 689 | pid | duration | query 690 | -------+-----------------+--------------------------------------------------------------------------------------- 691 | 19578 | 02:29:11.200129 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1450645 LIMIT 1 692 | 19465 | 02:26:05.542653 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1889881 LIMIT 1 693 | 19632 | 02:24:46.962818 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1581884 LIMIT 1 694 | (truncated results for brevity) 695 | ``` 696 | 697 | 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`. 698 | 699 | ### `records_rank` 700 | 701 | ```ruby 702 | RailsPgExtras.records_rank 703 | 704 | $ rake pg_extras:records_rank 705 | 706 | name | estimated_count 707 | -----------------------------------+----------------- 708 | tastypie_apiaccess | 568891 709 | notifications_event | 381227 710 | core_todo | 178614 711 | core_comment | 123969 712 | notifications_notification | 102101 713 | django_session | 68078 714 | (truncated results for brevity) 715 | ``` 716 | 717 | 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. 718 | 719 | ### `bloat` 720 | 721 | ```ruby 722 | RailsPgExtras.bloat 723 | 724 | $ rake pg_extras:bloat 725 | 726 | type | schemaname | object_name | bloat | waste 727 | -------+------------+-------------------------------+-------+---------- 728 | table | public | bloated_table | 1.1 | 98 MB 729 | table | public | other_bloated_table | 1.1 | 58 MB 730 | index | public | bloated_table::bloated_index | 3.7 | 34 MB 731 | table | public | clean_table | 0.2 | 3808 kB 732 | table | public | other_clean_table | 0.3 | 1576 kB 733 | (truncated results for brevity) 734 | ``` 735 | 736 | 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. 737 | 738 | [More info](https://pawelurbanek.com/postgresql-fix-performance#bloat) 739 | 740 | ### `vacuum_stats` 741 | 742 | ```ruby 743 | RailsPgExtras.vacuum_stats 744 | 745 | $ rake pg_extras:vacuum_stats 746 | 747 | schema | table | last_vacuum | last_autovacuum | rowcount | dead_rowcount | autovacuum_threshold | expect_autovacuum 748 | --------+-----------------------+-------------+------------------+----------------+----------------+----------------------+------------------- 749 | public | log_table | | 2013-04-26 17:37 | 18,030 | 0 | 3,656 | 750 | public | data_table | | 2013-04-26 13:09 | 79 | 28 | 66 | 751 | public | other_table | | 2013-04-26 11:41 | 41 | 47 | 58 | 752 | public | queue_table | | 2013-04-26 17:39 | 12 | 8,228 | 52 | yes 753 | (truncated results for brevity) 754 | ``` 755 | 756 | 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. 757 | 758 | ### `kill_all` 759 | 760 | ```ruby 761 | 762 | RailsPgExtras.kill_all 763 | 764 | ``` 765 | 766 | 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. 767 | 768 | ### `kill_pid` 769 | 770 | ```ruby 771 | 772 | RailsPgExtras.kill_pid(args: { pid: 4657 }) 773 | 774 | ``` 775 | 776 | This commands kills currently active database connection by its `pid` number. You can use `connections` method to find the correct `pid` values. 777 | 778 | ### `pg_stat_statements_reset` 779 | 780 | ```ruby 781 | RailsPgExtras.pg_stat_statements_reset 782 | ``` 783 | 784 | This command discards all statistics gathered so far by pg_stat_statements. 785 | 786 | ### `buffercache_stats` 787 | 788 | ```ruby 789 | RailsPgExtras.buffercache_stats(args: { limit: 10 }) 790 | ``` 791 | 792 | 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. 793 | 794 | ### `buffercache_usage` 795 | 796 | ```ruby 797 | RailsPgExtras.buffercache_usage(args: { limit: 20 }) 798 | ``` 799 | 800 | This command calculates how many blocks from which table are currently cached. 801 | 802 | ### `extensions` 803 | 804 | ```ruby 805 | 806 | RailsPgExtras.extensions 807 | 808 | $ rake pg_extras:extensions 809 | 810 | | pg_stat_statements | 1.7 | 1.7 | track execution statistics of all SQL statements executed 811 | (truncated results for brevity) 812 | 813 | ``` 814 | 815 | This command lists all the currently installed and available PostgreSQL extensions. 816 | 817 | ### `connections` 818 | 819 | ```ruby 820 | 821 | RailsPgExtras.connections 822 | 823 | +----------------------------------------------------------------+ 824 | | Returns the list of all active database connections | 825 | +------------------+--------------------------+------------------+ 826 | | username | pid | client_address | application_name | 827 | +------------------+--------------------------+------------------+ 828 | | postgres | 15962 | 172.31.69.166/32 | sidekiq | 829 | | postgres | 16810 | 172.31.69.166/32 | bin/rails | 830 | +------------------+--------------------------+------------------+ 831 | 832 | ``` 833 | 834 | This command returns the list of all active database connections. 835 | 836 | ### mandelbrot 837 | 838 | ```ruby 839 | RailsPgExtras.mandelbrot 840 | 841 | $ rake pg_extras:mandelbrot 842 | ``` 843 | 844 | This command outputs the Mandelbrot set, calculated through SQL. 845 | 846 | ## Testing 847 | 848 | ```bash 849 | cp docker-compose.yml.sample docker-compose.yml 850 | docker compose up -d 851 | rake test_all 852 | ``` 853 | 854 | ## Query sources 855 | 856 | - [https://github.com/heroku/heroku-pg-extras](https://github.com/heroku/heroku-pg-extras) 857 | - [https://hakibenita.com/postgresql-unused-index-size](https://hakibenita.com/postgresql-unused-index-size) 858 | - [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) 859 | - [https://wiki.postgresql.org/wiki/Index_Maintenance](https://wiki.postgresql.org/wiki/Index_Maintenance) 860 | -------------------------------------------------------------------------------- /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 | QUERIES = RubyPgExtras::QUERIES 16 | DEFAULT_ARGS = RubyPgExtras::DEFAULT_ARGS 17 | NEW_PG_STAT_STATEMENTS = RubyPgExtras::NEW_PG_STAT_STATEMENTS 18 | PG_STAT_STATEMENTS_17 = RubyPgExtras::PG_STAT_STATEMENTS_17 19 | 20 | QUERIES.each do |query_name| 21 | define_singleton_method query_name do |options = {}| 22 | run_query( 23 | query_name: query_name, 24 | in_format: options.fetch(:in_format, :display_table), 25 | args: options.fetch(:args, {}), 26 | ) 27 | end 28 | end 29 | 30 | def self.run_query(query_name:, in_format:, args: {}) 31 | RubyPgExtras.run_query_base( 32 | query_name: query_name, 33 | conn: connection, 34 | exec_method: :execute, 35 | in_format: in_format, 36 | args: args, 37 | ) 38 | end 39 | 40 | def self.diagnose(in_format: :display_table) 41 | data = RailsPgExtras::DiagnoseData.call 42 | 43 | if in_format == :display_table 44 | RailsPgExtras::DiagnosePrint.call(data) 45 | elsif in_format == :hash 46 | data 47 | else 48 | raise "Invalid 'in_format' argument!" 49 | end 50 | end 51 | 52 | def self.measure_duration(&block) 53 | starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 54 | block.call 55 | ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 56 | (ending - starting) * 1000 57 | end 58 | 59 | def self.measure_queries(&block) 60 | queries = {} 61 | sql_duration = 0 62 | 63 | method_name = if ActiveSupport::Notifications.respond_to?(:monotonic_subscribe) 64 | :monotonic_subscribe 65 | else 66 | :subscribe 67 | end 68 | 69 | subscriber = ActiveSupport::Notifications.public_send(method_name, "sql.active_record") do |_name, start, finish, _id, payload| 70 | unless payload[:name] =~ /SCHEMA/ 71 | key = payload[:sql] 72 | queries[key] ||= { count: 0, total_duration: 0, min_duration: nil, max_duration: nil } 73 | queries[key][:count] += 1 74 | duration = (finish - start) * 1000 75 | queries[key][:total_duration] += duration 76 | sql_duration += duration 77 | 78 | if queries[key][:min_duration] == nil || queries[key][:min_duration] > duration 79 | queries[key][:min_duration] = duration.round(2) 80 | end 81 | 82 | if queries[key][:max_duration] == nil || queries[key][:max_duration] < duration 83 | queries[key][:max_duration] = duration.round(2) 84 | end 85 | end 86 | end 87 | 88 | total_duration = measure_duration do 89 | block.call 90 | end 91 | 92 | queries = queries.reduce({}) do |agg, val| 93 | val[1][:avg_duration] = (val[1][:total_duration] / val[1][:count]).round(2) 94 | val[1][:total_duration] = val[1][:total_duration].round(2) 95 | agg.merge(val[0] => val[1]) 96 | end 97 | 98 | ActiveSupport::Notifications.unsubscribe(subscriber) 99 | { 100 | count: queries.reduce(0) { |agg, val| agg + val[1].fetch(:count) }, 101 | queries: queries, 102 | total_duration: total_duration.round(2), 103 | sql_duration: sql_duration.round(2), 104 | } 105 | end 106 | 107 | def self.index_info(args: {}, in_format: :display_table) 108 | data = RailsPgExtras::IndexInfo.call(args[:table_name]) 109 | 110 | if in_format == :display_table 111 | RailsPgExtras::IndexInfoPrint.call(data) 112 | elsif in_format == :hash 113 | data 114 | elsif in_format == :array 115 | data.map(&:values) 116 | else 117 | raise "Invalid 'in_format' argument!" 118 | end 119 | end 120 | 121 | def self.table_info(args: {}, in_format: :display_table) 122 | data = RailsPgExtras::TableInfo.call(args[:table_name]) 123 | 124 | if in_format == :display_table 125 | RailsPgExtras::TableInfoPrint.call(data) 126 | elsif in_format == :hash 127 | data 128 | elsif in_format == :array 129 | data.map(&:values) 130 | else 131 | raise "Invalid 'in_format' argument!" 132 | end 133 | end 134 | 135 | def self.missing_fk_indexes(args: {}, in_format: :display_table) 136 | result = RailsPgExtras::MissingFkIndexes.call(args[:table_name]) 137 | RubyPgExtras.display_result(result, title: "Missing foreign key indexes", in_format: in_format) 138 | end 139 | 140 | def self.missing_fk_constraints(args: {}, in_format: :display_table) 141 | result = RailsPgExtras::MissingFkConstraints.call(args[:table_name]) 142 | RubyPgExtras.display_result(result, title: "Missing foreign key constraints", in_format: in_format) 143 | end 144 | 145 | def self.connection 146 | if (db_url = ENV["RAILS_PG_EXTRAS_DATABASE_URL"]) 147 | connector = ActiveRecord::Base.establish_connection(db_url) 148 | 149 | if connector.respond_to?(:connection) 150 | connector.connection 151 | elsif connector.respond_to?(:lease_connection) 152 | connector.lease_connection 153 | else 154 | raise "Unsupported connector: #{connector.class}" 155 | end 156 | else 157 | ActiveRecord::Base.connection 158 | end 159 | end 160 | end 161 | 162 | require "rails_pg_extras/web" 163 | require "rails_pg_extras/configuration" 164 | require "rails_pg_extras/railtie" if defined?(Rails) 165 | -------------------------------------------------------------------------------- /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/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.10" 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/99d60238bb7f7c9dc0e6e0553c02cc83e305acb0/marginalia-logs.png -------------------------------------------------------------------------------- /pg-extras-ui-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/99d60238bb7f7c9dc0e6e0553c02cc83e305acb0/pg-extras-ui-3.png -------------------------------------------------------------------------------- /rails-pg-extras-diagnose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawurb/rails-pg-extras/99d60238bb7f7c9dc0e6e0553c02cc83e305acb0/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 "rails" 20 | s.add_development_dependency "rake" 21 | s.add_development_dependency "rspec" 22 | s.add_development_dependency "rufo" 23 | 24 | if s.respond_to?(:metadata=) 25 | s.metadata = { "rubygems_mfa_required" => "true" } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------