├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console ├── gocmdpev └── setup ├── circle.yml ├── gemfiles ├── activerecord_4.gemfile ├── activerecord_4.gemfile.lock ├── activerecord_5.gemfile ├── activerecord_5.gemfile.lock ├── activerecord_6.gemfile ├── activerecord_6.gemfile.lock ├── activerecord_7.gemfile └── activerecord_7.gemfile.lock ├── gocmdpev.png ├── lib ├── eyeballs │ ├── errors.rb │ ├── includes.rb │ ├── inspector.rb │ └── version.rb └── pg-eyeballs.rb ├── pg-eyeballs.gemspec └── spec ├── eyeballs └── inspector_spec.rb ├── eyeballs_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | *.gem 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.1 5 | before_install: gem install bundler -v 1.12.5 6 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-4" do 2 | gem "activerecord", ">= 4.0.0", "< 5.0.0" 3 | gem "pg", ">= 0.19.0", "< 1.0.0" 4 | end 5 | 6 | appraise "activerecord-5" do 7 | gem "activerecord", ">= 5.0.0", "< 6.0.0" 8 | gem "pg", ">= 0.19.0", "< 1.0.0" 9 | end 10 | 11 | appraise "activerecord-6" do 12 | gem "activerecord", ">= 6.0.0", "< 7.0.0" 13 | end 14 | 15 | appraise "activerecord-7" do 16 | gem "activerecord", ">= 7.0.0", "< 8.0.0" 17 | end -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at bradurani@gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'pry' 4 | gem 'pry-byebug' 5 | gem 'awesome_print' 6 | gem 'appraisal' 7 | 8 | # Specify your gem's dependencies in pg-eyeballs.gemspec 9 | gemspec 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Brad Urani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/bradurani/pg-eyeballs.svg?style=svg)](https://circleci.com/gh/bradurani/pg-eyeballs) 2 | # pg-eyeballs 👀 3 | 4 | `pg-eyeballs` is a ruby gem that gives you detailed information about how the 5 | SQL queries created by the active record code you write are executed by the database. 6 | It gives you an easy, ruby friendly way to see the output of the Postgres 7 | [`EXPLAIN` command](https://www.postgresql.org/docs/9.4/static/using-explain.html) and integrates with the popular query analysis tool [`gocmdpev`](https://github.com/simon-engledew/gocmdpev). 8 | 9 | Using it you can see: 10 | - What queries were run 11 | - How long they took 12 | - Which indexes were used 13 | - Which algorithms were used 14 | - Much more! 15 | 16 | ## Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem 'pg-eyeballs' 22 | ``` 23 | 24 | And then execute: 25 | 26 | $ bundle 27 | 28 | Or install it yourself as: 29 | 30 | $ gem install pg-eyeballs 31 | 32 | ## Usage 33 | 34 | ### explain(options: [:analyze, :verbose, :costs, :buffers], format: :text) 35 | 36 | ```ruby 37 | User.all.eyeballs.explain 38 | 39 | ["Seq Scan on public.users (cost=0.00..22.30 rows=1230 width=36) (actual time=0.002..0.002 rows=1 loops=1) 40 | Output: id, email 41 | Buffers: shared hit=1 42 | Planning time: 0.014 ms 43 | Execution time: 0.009 ms" 44 | ] 45 | ``` 46 | Most eyeballs methods return an array because an `ActiveRecord::Relation` can run 47 | more than one query, for instance when it has a `preload` or with certain 48 | subqueries 49 | ```ruby 50 | User.all.preload(:profiles).eyeballs.explain(options: [:verbose], format: :yaml) 51 | ['- Plan: 52 | Node Type: "Seq Scan" 53 | Relation Name: "users" 54 | Schema: "public" 55 | Alias: "users" 56 | Startup Cost: 0.00 57 | Total Cost: 22.30 58 | Plan Rows: 1230 59 | Plan Width: 36 60 | Output: 61 | - "id" 62 | - "email"', 63 | '- Plan: 64 | Node Type: "Seq Scan" 65 | Relation Name: "profiles" 66 | Schema: "public" 67 | Alias: "profiles" 68 | Startup Cost: 0.00\ 69 | Total Cost: 36.75 70 | Plan Rows: 11 71 | Plan Width: 8 72 | Output: 73 | - "id" 74 | - "user_id" 75 | Filter: "(profiles.user_id = 1)"' 76 | ] 77 | ``` 78 | **formats:** :text, :xml, :json, :yaml 79 | 80 | ### explain_queries(options: [:analyze, :verbose, :costs, :buffers], format: :text) 81 | ```ruby 82 | User.all.preload(:profiles).eyeballs.explain_queries 83 | ["EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"users\".* FROM \"users\"", 84 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"profiles\".* FROM \"profiles\" WHERE \"profiles\".\"user_id\" IN (1)"] 85 | ``` 86 | **formats:** :text, :xml, :json, :yaml 87 | 88 | ### log_json(options: [:analyze, :verbose, :costs, :buffers]) 89 | Prints each JSON plan on a separate line. This is useful for command line 90 | processing with [`xargs`](https://linux.die.net/man/1/xargs) and [`jq`](https://stedolan.github.io/jq/) or 91 | [`gocmdpev`](https://github.com/simon-engledew/gocmdpev) 92 | ```ruby 93 | User.all.preload(:profiles).eyeballs.log_json 94 | ``` 95 | 96 | ### queries 97 | ```ruby 98 | User.all.preload(:profiles).eyeballs.queries 99 | ["SELECT \"users\".* FROM \"users\"", 100 | "SELECT \"profiles\".* FROM \"profiles\" WHERE \"profiles\".\"user_id\" IN (1)"] 101 | ``` 102 | 103 | ### to_hash_array(options: [:analyze, :verbose, :costs, :buffers]) 104 | ```ruby 105 | User.all.eyeballs.to_hash_array 106 | [[{"Plan"=>{ 107 | "Node Type"=>"Seq Scan", 108 | "Relation Name"=>"users", 109 | "Schema"=>"public", 110 | "Alias"=>"users", 111 | "Startup Cost"=>0.0, 112 | "Total Cost"=>22.3, 113 | "Plan Rows"=>1230, 114 | "Plan Width"=>36, 115 | "Actual Startup Time"=>0.001, 116 | "Actual Total Time"=>0.001, 117 | "Actual Rows"=>1, 118 | "Actual Loops"=>1, 119 | "Output"=>["id", "email"], 120 | "Shared Hit Blocks"=>1, 121 | "Shared Read Blocks"=>0, 122 | "Shared Dirtied Blocks"=>0, 123 | "Shared Written Blocks"=>0, 124 | "Local Hit Blocks"=>0, 125 | "Local Read Blocks"=>0, 126 | "Local Dirtied Blocks"=>0, 127 | "Local Written Blocks"=>0, 128 | "Temp Read Blocks"=>0, 129 | "Temp Written Blocks"=>0, 130 | "I/O Read Time"=>0.0, 131 | "I/O Write Time"=>0.0}, 132 | "Planning Time"=>0.014, 133 | "Triggers"=>[], 134 | "Execution Time"=>0.007}] 135 | ] 136 | ``` 137 | 138 | ### to_json(options: [:analyze, :verbose, :costs, :buffers]) 139 | **alias for** `explain(format: :json)` 140 | 141 | ### to_s(options: [:analyze, :verbose, :costs, :buffers]) 142 | 143 | ```ruby 144 | User.all.preload(:profiles).eyeballs.to_s 145 | "Seq Scan on public.users (cost=0.00..22.30 rows=1230 width=36) (actual time=0.001..0.002 rows=1 loops=1) 146 | Output: id, email 147 | Buffers: shared hit=1 148 | Planning time: 0.010 ms 149 | Execution time: 0.005 ms 150 | 151 | Seq Scan on public.profiles (cost=0.00..36.75 rows=11 width=8) (actual time=0.002..0.002 rows=1 loops=1) 152 | Output: id, user_id 153 | Filter: (profiles.user_id = 1) 154 | Buffers: shared hit=1 155 | Planning time: 0.013 ms 156 | Execution time: 0.006 ms" 157 | ``` 158 | 159 | ## Integration with `gocmdpev` 160 | 161 | `pg-eyeballs` integrates with 162 | [`gocmdpev`](https://github.com/simon-engledew/gocmdpev). If you have `gocmdpev` 163 | installed, you can use it in your Rails console: 164 | ```ruby 165 | User.all.preload(:profiles).eyeballs.gocmdpev 166 | ``` 167 | 168 | ![gocmdpev](https://raw.githubusercontent.com/bradurani/pg-eyeballs/master/gocmdpev.png "Using 169 | gocmdpev in the Rails console") 170 | 171 | You can also use `pg-eyeballs` and `gocmdpev` together from the command line. 172 | First, alias the included command. From inside the directory of you Rails 173 | project run 174 | ```bash 175 | alias eyeballs=$(bundle show pg-eyeballs)/bin/gocmdpev 176 | ``` 177 | To use, also from inside your Rails project directory, run 178 | ```bash 179 | eyeballs User.preload(:profiles) 180 | ``` 181 | 182 | ## Compatibility 183 | 184 | `pg-eyeballs` has been tested with Rails versions 4 and 5. It may work on 185 | earlier versions, but I haven't tried it. 186 | 187 | ## Development 188 | 189 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 190 | 191 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 192 | 193 | ## Contributing 194 | 195 | Bug reports and pull requests are welcome on GitHub at https://github.com/bradurani/pg-eyeballs. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 196 | 197 | ### Running the tests 198 | 199 | First, `bundle install`. After this, if you are using the default 200 | database, first thing is to run `createdb eyeballs_test`. After this 201 | you can run the tests with `rake`. 202 | 203 | 204 | ## License 205 | 206 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 207 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "pg-eyeballs" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "pry" 14 | Pry.start 15 | -------------------------------------------------------------------------------- /bin/gocmdpev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | bundle exec rails runner "$1.eyeballs.log_json" | while read x; do echo "$x" | gocmdpev; echo; done 3 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | POSTGRES_DB_DATABASE: circle_test 4 | POSTGRES_DB_USERNAME: ubuntu 5 | POSTGRES_DB_PASSWORD: '' 6 | database: 7 | override: 8 | - echo 'using circle_test' 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "pry-byebug" 7 | gem "awesome_print" 8 | gem "appraisal" 9 | gem "activerecord", ">= 4.0.0", "< 5.0.0" 10 | gem "pg", ">= 0.19.0", "< 1.0.0" 11 | 12 | gemspec path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | pg-eyeballs (1.3.0) 5 | activerecord (>= 4.0, < 8.0) 6 | pg 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (4.2.7.1) 12 | activesupport (= 4.2.7.1) 13 | builder (~> 3.1) 14 | activerecord (4.2.7.1) 15 | activemodel (= 4.2.7.1) 16 | activesupport (= 4.2.7.1) 17 | arel (~> 6.0) 18 | activesupport (4.2.7.1) 19 | i18n (~> 0.7) 20 | json (~> 1.7, >= 1.7.7) 21 | minitest (~> 5.1) 22 | thread_safe (~> 0.3, >= 0.3.4) 23 | tzinfo (~> 1.1) 24 | appraisal (2.1.0) 25 | bundler 26 | rake 27 | thor (>= 0.14.0) 28 | arel (6.0.3) 29 | awesome_print (1.7.0) 30 | builder (3.2.2) 31 | byebug (9.0.6) 32 | coderay (1.1.1) 33 | database_cleaner (1.5.3) 34 | diff-lcs (1.2.5) 35 | i18n (0.7.0) 36 | json (1.8.6) 37 | method_source (0.8.2) 38 | minitest (5.9.1) 39 | pg (0.19.0) 40 | pry (0.10.4) 41 | coderay (~> 1.1.0) 42 | method_source (~> 0.8.1) 43 | slop (~> 3.4) 44 | pry-byebug (3.4.0) 45 | byebug (~> 9.0) 46 | pry (~> 0.10) 47 | rake (10.5.0) 48 | rspec (3.5.0) 49 | rspec-core (~> 3.5.0) 50 | rspec-expectations (~> 3.5.0) 51 | rspec-mocks (~> 3.5.0) 52 | rspec-core (3.5.4) 53 | rspec-support (~> 3.5.0) 54 | rspec-expectations (3.5.0) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.5.0) 57 | rspec-mocks (3.5.0) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.5.0) 60 | rspec-support (3.5.0) 61 | slop (3.6.0) 62 | thor (0.19.1) 63 | thread_safe (0.3.5) 64 | tzinfo (1.2.2) 65 | thread_safe (~> 0.1) 66 | 67 | PLATFORMS 68 | ruby 69 | 70 | DEPENDENCIES 71 | activerecord (>= 4.0.0, < 5.0.0) 72 | appraisal 73 | awesome_print 74 | bundler (~> 1.12) 75 | database_cleaner 76 | pg (>= 0.19.0, < 1.0.0) 77 | pg-eyeballs! 78 | pry 79 | pry-byebug 80 | rake (~> 10.0) 81 | rspec (~> 3.0) 82 | 83 | BUNDLED WITH 84 | 1.17.3 85 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "pry-byebug" 7 | gem "awesome_print" 8 | gem "appraisal" 9 | gem "activerecord", ">= 5.0.0", "< 6.0.0" 10 | gem "pg", ">= 0.19.0", "< 1.0.0" 11 | 12 | gemspec path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | pg-eyeballs (1.3.0) 5 | activerecord (>= 4.0, < 8.0) 6 | pg 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (5.0.0.1) 12 | activesupport (= 5.0.0.1) 13 | activerecord (5.0.0.1) 14 | activemodel (= 5.0.0.1) 15 | activesupport (= 5.0.0.1) 16 | arel (~> 7.0) 17 | activesupport (5.0.0.1) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | tzinfo (~> 1.1) 22 | appraisal (2.1.0) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | arel (7.1.2) 27 | awesome_print (1.7.0) 28 | byebug (9.0.6) 29 | coderay (1.1.1) 30 | concurrent-ruby (1.0.2) 31 | database_cleaner (1.5.3) 32 | diff-lcs (1.2.5) 33 | i18n (0.7.0) 34 | method_source (0.8.2) 35 | minitest (5.9.1) 36 | pg (0.21.0) 37 | pry (0.10.4) 38 | coderay (~> 1.1.0) 39 | method_source (~> 0.8.1) 40 | slop (~> 3.4) 41 | pry-byebug (3.4.0) 42 | byebug (~> 9.0) 43 | pry (~> 0.10) 44 | rake (10.5.0) 45 | rspec (3.5.0) 46 | rspec-core (~> 3.5.0) 47 | rspec-expectations (~> 3.5.0) 48 | rspec-mocks (~> 3.5.0) 49 | rspec-core (3.5.4) 50 | rspec-support (~> 3.5.0) 51 | rspec-expectations (3.5.0) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.5.0) 54 | rspec-mocks (3.5.0) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.5.0) 57 | rspec-support (3.5.0) 58 | slop (3.6.0) 59 | thor (0.19.1) 60 | thread_safe (0.3.5) 61 | tzinfo (1.2.2) 62 | thread_safe (~> 0.1) 63 | 64 | PLATFORMS 65 | ruby 66 | 67 | DEPENDENCIES 68 | activerecord (>= 5.0.0, < 6.0.0) 69 | appraisal 70 | awesome_print 71 | bundler (~> 1.12) 72 | database_cleaner 73 | pg (>= 0.19.0, < 1.0.0) 74 | pg-eyeballs! 75 | pry 76 | pry-byebug 77 | rake (~> 10.0) 78 | rspec (~> 3.0) 79 | 80 | BUNDLED WITH 81 | 1.17.3 82 | -------------------------------------------------------------------------------- /gemfiles/activerecord_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "pry-byebug" 7 | gem "awesome_print" 8 | gem "appraisal" 9 | gem "activerecord", ">= 6.0.0", "< 7.0.0" 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_6.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | pg-eyeballs (1.3.0) 5 | activerecord (>= 4.0, < 8.0) 6 | pg 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (6.1.4.1) 12 | activesupport (= 6.1.4.1) 13 | activerecord (6.1.4.1) 14 | activemodel (= 6.1.4.1) 15 | activesupport (= 6.1.4.1) 16 | activesupport (6.1.4.1) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (>= 1.6, < 2) 19 | minitest (>= 5.1) 20 | tzinfo (~> 2.0) 21 | zeitwerk (~> 2.3) 22 | appraisal (2.4.1) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | awesome_print (1.9.2) 27 | byebug (11.1.3) 28 | coderay (1.1.3) 29 | concurrent-ruby (1.1.9) 30 | database_cleaner (2.0.1) 31 | database_cleaner-active_record (~> 2.0.0) 32 | database_cleaner-active_record (2.0.1) 33 | activerecord (>= 5.a) 34 | database_cleaner-core (~> 2.0.0) 35 | database_cleaner-core (2.0.1) 36 | diff-lcs (1.4.4) 37 | i18n (1.8.10) 38 | concurrent-ruby (~> 1.0) 39 | method_source (1.0.0) 40 | minitest (5.14.4) 41 | pg (1.4.5) 42 | pry (0.14.1) 43 | coderay (~> 1.1) 44 | method_source (~> 1.0) 45 | pry-byebug (3.8.0) 46 | byebug (~> 11.0) 47 | pry (~> 0.10) 48 | rake (10.5.0) 49 | rspec (3.10.0) 50 | rspec-core (~> 3.10.0) 51 | rspec-expectations (~> 3.10.0) 52 | rspec-mocks (~> 3.10.0) 53 | rspec-core (3.10.1) 54 | rspec-support (~> 3.10.0) 55 | rspec-expectations (3.10.1) 56 | diff-lcs (>= 1.2.0, < 2.0) 57 | rspec-support (~> 3.10.0) 58 | rspec-mocks (3.10.2) 59 | diff-lcs (>= 1.2.0, < 2.0) 60 | rspec-support (~> 3.10.0) 61 | rspec-support (3.10.2) 62 | thor (1.1.0) 63 | tzinfo (2.0.4) 64 | concurrent-ruby (~> 1.0) 65 | zeitwerk (2.4.2) 66 | 67 | PLATFORMS 68 | ruby 69 | 70 | DEPENDENCIES 71 | activerecord (>= 6.0.0, < 7.0.0) 72 | appraisal 73 | awesome_print 74 | bundler (~> 1.12) 75 | database_cleaner 76 | pg-eyeballs! 77 | pry 78 | pry-byebug 79 | rake (~> 10.0) 80 | rspec (~> 3.0) 81 | 82 | BUNDLED WITH 83 | 1.17.3 84 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "pry-byebug" 7 | gem "awesome_print" 8 | gem "appraisal" 9 | gem "activerecord", ">= 7.0.0", "< 8.0.0" 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_7.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | pg-eyeballs (1.3.0) 5 | activerecord (>= 4.0, < 8.0) 6 | pg 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (7.0.4.2) 12 | activesupport (= 7.0.4.2) 13 | activerecord (7.0.4.2) 14 | activemodel (= 7.0.4.2) 15 | activesupport (= 7.0.4.2) 16 | activesupport (7.0.4.2) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (>= 1.6, < 2) 19 | minitest (>= 5.1) 20 | tzinfo (~> 2.0) 21 | appraisal (2.4.1) 22 | bundler 23 | rake 24 | thor (>= 0.14.0) 25 | awesome_print (1.9.2) 26 | byebug (11.1.3) 27 | coderay (1.1.3) 28 | concurrent-ruby (1.2.0) 29 | database_cleaner (2.0.1) 30 | database_cleaner-active_record (~> 2.0.0) 31 | database_cleaner-active_record (2.0.1) 32 | activerecord (>= 5.a) 33 | database_cleaner-core (~> 2.0.0) 34 | database_cleaner-core (2.0.1) 35 | diff-lcs (1.5.0) 36 | i18n (1.12.0) 37 | concurrent-ruby (~> 1.0) 38 | method_source (1.0.0) 39 | minitest (5.17.0) 40 | pg (1.4.5) 41 | pry (0.14.2) 42 | coderay (~> 1.1) 43 | method_source (~> 1.0) 44 | pry-byebug (3.10.1) 45 | byebug (~> 11.0) 46 | pry (>= 0.13, < 0.15) 47 | rake (10.5.0) 48 | rspec (3.12.0) 49 | rspec-core (~> 3.12.0) 50 | rspec-expectations (~> 3.12.0) 51 | rspec-mocks (~> 3.12.0) 52 | rspec-core (3.12.0) 53 | rspec-support (~> 3.12.0) 54 | rspec-expectations (3.12.2) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.12.0) 57 | rspec-mocks (3.12.3) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.12.0) 60 | rspec-support (3.12.0) 61 | thor (1.2.1) 62 | tzinfo (2.0.6) 63 | concurrent-ruby (~> 1.0) 64 | 65 | PLATFORMS 66 | ruby 67 | 68 | DEPENDENCIES 69 | activerecord (>= 7.0.0, < 8.0.0) 70 | appraisal 71 | awesome_print 72 | bundler (~> 1.12) 73 | database_cleaner 74 | pg-eyeballs! 75 | pry 76 | pry-byebug 77 | rake (~> 10.0) 78 | rspec (~> 3.0) 79 | 80 | BUNDLED WITH 81 | 1.17.3 82 | -------------------------------------------------------------------------------- /gocmdpev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradurani/pg-eyeballs/411516ab6eafa8853ddc312fbf089ea4e2abf0de/gocmdpev.png -------------------------------------------------------------------------------- /lib/eyeballs/errors.rb: -------------------------------------------------------------------------------- 1 | class Eyeballs::UnknownFormatError < StandardError 2 | end 3 | 4 | class Eyeballs::UnknownOptionError < StandardError 5 | end 6 | -------------------------------------------------------------------------------- /lib/eyeballs/includes.rb: -------------------------------------------------------------------------------- 1 | module Eyeballs::RelationMixin 2 | def eyeballs 3 | Eyeballs::Inspector.new(self) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/eyeballs/inspector.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module Eyeballs 4 | class Inspector 5 | 6 | OPTIONS = [:analyze, :verbose, :costs, :buffers] 7 | FORMATS = [:text, :xml, :json, :yaml] 8 | 9 | def initialize(relation) 10 | @relation = relation 11 | end 12 | 13 | def explain(format: :text, options: OPTIONS) 14 | @explain ||= explain_queries(format: format, options: options).map do |query| 15 | run_query(query) 16 | end 17 | end 18 | 19 | def explain_queries(format: :text, options: OPTIONS) 20 | validate_format!(format) 21 | validate_options!(options) 22 | @explain_queries ||= queries.map do |query| 23 | explain_query(query, format, options) 24 | end 25 | end 26 | 27 | def queries 28 | @relation.connection.to_sql(query_array).map { |query| 29 | build_sql(query) 30 | } 31 | end 32 | 33 | def to_s(options: OPTIONS) 34 | explain.join("\n\n") 35 | end 36 | 37 | def to_json(options: OPTIONS) 38 | explain(options: options, format: :json) 39 | end 40 | 41 | def to_hash_array(options: OPTIONS) 42 | to_json(options: options).map { |json| JSON.parse(json) } 43 | end 44 | 45 | def inspect 46 | "Eyeballs::Inspector: #{@relation.to_s}" 47 | end 48 | 49 | def log_json(options: OPTIONS) 50 | to_hash_array.each { |h| puts "#{h.to_json }" } 51 | nil 52 | end 53 | 54 | def gocmdpev 55 | to_hash_array.each do |h| 56 | begin 57 | tmp = Tempfile.new('pg-eyeballs') 58 | tmp.write(h.to_json) 59 | tmp.close 60 | system("cat #{tmp.path} | gocmdpev") 61 | ensure 62 | tmp.close 63 | tmp.unlink 64 | end 65 | end 66 | nil 67 | end 68 | 69 | private 70 | 71 | def query_array 72 | @relation.send(:collecting_queries_for_explain) do 73 | @relation.send(:exec_queries) 74 | end 75 | end 76 | 77 | def validate_format!(format) 78 | unless FORMATS.include?(format) 79 | raise Eyeballs::UnknownFormatError, "Unknown Format #{format}" 80 | end 81 | end 82 | 83 | def validate_options!(options) 84 | options.each do |option| 85 | unless OPTIONS.include?(option) 86 | raise Eyeballs::UnknownOptionError, "Unknown Option #{option}" 87 | end 88 | end 89 | end 90 | 91 | def explain_query(query, format, options) 92 | "EXPLAIN (#{explain_options(format, options)}) #{query}" 93 | end 94 | 95 | def explain_options(format, options) 96 | options.map(&:upcase).tap { |a| a << "FORMAT #{format.upcase}" }.join(',') 97 | end 98 | 99 | def run_query(sql) 100 | @relation.connection.raw_connection.exec(sql).values.join("\n") 101 | end 102 | 103 | def build_sql(query_binding) 104 | query_binding[1].each.with_index.reduce(query_binding[0]) do |sql,(value, index)| 105 | sql.sub("$#{index + 1}", @relation.connection.quote(extract_value(value))) 106 | end 107 | end 108 | 109 | def extract_value(value) 110 | if value.is_a?(Array) #Rails 4 111 | value.last 112 | elsif value.is_a?(ActiveRecord::Relation::QueryAttribute) #Rails 5 113 | value.value 114 | end 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/eyeballs/version.rb: -------------------------------------------------------------------------------- 1 | module Eyeballs 2 | VERSION = "1.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/pg-eyeballs.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'pg' 3 | 4 | module Eyeballs 5 | end 6 | 7 | Dir[File.join(File.dirname(__FILE__), 'eyeballs', '*.rb')].each {|file| require file } 8 | 9 | ActiveRecord::Relation.include Eyeballs::RelationMixin 10 | -------------------------------------------------------------------------------- /pg-eyeballs.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'eyeballs/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "pg-eyeballs" 8 | spec.version = Eyeballs::VERSION 9 | spec.authors = ["Brad Urani"] 10 | spec.email = ["bradurani@gmail.com"] 11 | 12 | spec.summary = 'A Ruby gem for using the postgres explain command with Active Record' 13 | spec.description = 'pg-eyeballs is a ruby gem that gives you detailed information about how the SQL queries created by the active record code you write are executed by the database. It gives you an easy, ruby friendly way to see the output of the Postgres EXPLAIN command and integrates with the popular query analysis tool gocmdpev' 14 | spec.homepage = "http://github.com/bradurani/pg-eyeballs" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['allowed_push_host'] = "https://rubygems.org" 21 | else 22 | raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 23 | end 24 | 25 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_development_dependency "bundler", "~> 1.12" 31 | spec.add_development_dependency "rake", "~> 10.0" 32 | spec.add_development_dependency "rspec", "~> 3.0" 33 | spec.add_development_dependency "database_cleaner" 34 | 35 | spec.add_dependency "activerecord", ">=4.0", "<8.0" 36 | spec.add_dependency "pg" 37 | 38 | end 39 | -------------------------------------------------------------------------------- /spec/eyeballs/inspector_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Eyeballs::Inspector do 4 | 5 | let(:foo){ Foo.all.eyeballs } 6 | let(:foo_bar) do 7 | Foo.all.preload(:bars).eyeballs 8 | end 9 | 10 | 11 | describe :queries do 12 | context :foo do 13 | it 'returns array of queries' do 14 | expect(foo.queries.length).to eql 1 15 | expect(foo.queries[0]).to include 'SELECT "foos".* FROM "foos"' 16 | end 17 | end 18 | 19 | context :foo_bar do 20 | it 'returns array of queries' do 21 | expect(foo_bar.queries.length).to eql 2 22 | expect(foo_bar.queries[0]).to include 'SELECT "foos".* FROM "foos"' 23 | expect(foo_bar.queries[1]).to include 'SELECT "bars".* FROM "bars"' 24 | end 25 | end 26 | end 27 | 28 | describe :to_s do 29 | context :foo_bar do 30 | it 'concatenates the query plans with blank line' do 31 | expect(foo_bar.to_s).to include 'Seq Scan on public.foos (cost=' 32 | expect(foo_bar.to_s).to include 'Seq Scan on public.bars (cost=' 33 | end 34 | end 35 | end 36 | 37 | describe :explain_queries do 38 | it 'validates format' do 39 | expect { foo.explain_queries(format: :toml) }.to raise_error Eyeballs::UnknownFormatError 40 | end 41 | 42 | it 'validates options' do 43 | expect { foo.explain_queries(options: [:analyze, :explain]) }.to raise_error Eyeballs::UnknownOptionError 44 | end 45 | 46 | it 'generates explain queries' do 47 | expect(foo.explain_queries).to eql [ 48 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"foos\".* FROM \"foos\"" 49 | ] 50 | end 51 | 52 | it 'generates explain queries for multiple queries' do 53 | result = foo_bar.explain_queries 54 | rails4version = [ 55 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"foos\".* FROM \"foos\"", 56 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"bars\".* FROM \"bars\" WHERE \"bars\".\"foo_id\" IN (1)" 57 | ] 58 | rails5version = [ 59 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"foos\".* FROM \"foos\"", 60 | "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"bars\".* FROM \"bars\" WHERE \"bars\".\"foo_id\" = 1" 61 | ] 62 | expect(result == rails4version || result == rails5version).to be true 63 | end 64 | 65 | it 'generates explain query given options and format' do 66 | expect(foo.explain_queries(format: :json, options: [:analyze])).to eql [ 67 | "EXPLAIN (ANALYZE,FORMAT JSON) SELECT \"foos\".* FROM \"foos\"" 68 | ] 69 | end 70 | 71 | it 'generates sql with integer param' do 72 | expect(Foo.where(id: 1).eyeballs.explain_queries[0]).to eql "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"foos\".* FROM \"foos\" WHERE \"foos\".\"id\" = 1" 73 | end 74 | 75 | it 'generates sql with string param' do 76 | expect(Foo.where(name: 'brad').eyeballs.explain_queries[0]).to eql "EXPLAIN (ANALYZE,VERBOSE,COSTS,BUFFERS,FORMAT TEXT) SELECT \"foos\".* FROM \"foos\" WHERE \"foos\".\"name\" = 'brad'" 77 | end 78 | end 79 | 80 | describe :explain do 81 | it 'runs explain query' do 82 | explain_array = foo.explain 83 | expect(explain_array.length).to eql 1 84 | expect(explain_array[0]).to include "Seq Scan on public.foos (cost=" 85 | end 86 | 87 | it 'runs explain queries' do 88 | explain_array = foo_bar.explain 89 | expect(explain_array.length).to eql 2 90 | expect(explain_array[0]).to include "Seq Scan on public.foos (cost=" 91 | expect(explain_array[1]).to include "Seq Scan on public.bars (cost=" 92 | end 93 | 94 | it 'interpolates SQL args' do 95 | expect(Foo.where(id: 1).eyeballs.explain[0]).to include "Index Scan using foos_pkey" 96 | end 97 | 98 | it 'works with most data types' do 99 | expect(Baz.where(id: 1, name: 'brad', d: Date.parse('2016-08-25'), t: Time.now, b: true, n: 3.14).eyeballs.explain).to be_a Array 100 | end 101 | end 102 | 103 | describe :inspect do 104 | it 'displays class string' do 105 | expect(foo.inspect).to include 'Eyeballs::Inspector: #