├── README.md ├── composer.json ├── license.md ├── screenshot.jpg └── src ├── Config └── config.php ├── Helpers.php ├── Indexer.php ├── IndexerMiddleware.php ├── Outputs ├── Output.php └── Web.php ├── ServiceProvider.php └── Traits └── FetchesStackTrace.php /README.md: -------------------------------------------------------------------------------- 1 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](license.md) 2 | [![Latest Version on Packagist][ico-version]][link-packagist] 3 | [![Total Downloads][ico-downloads]][link-downloads] 4 | 5 | # Laravel Indexer 6 | 7 | Laravel Indexer monitors `SELECT` queries running on a page and allows to add database indexes to `SELECT` queries on the fly. It then presents results of `EXPLAIN` or MySQL's execution plan right on the page. The results presented by Indexer will help you see which indexes work best for different queries running on a page. 8 | 9 | Indexes *added by Indexer* are automatically removed after results are collected while keeping your existing indexes intact. 10 | 11 | > **CAUTION: PLEASE DO NOT USE THIS PACKAGE ON PRODUCTION!** 12 | Since this package adds indexes to database on the fly, it is strongly recommended NOT to use this package in your production environment. 13 | 14 | > **Note** Since indexes are added and then removed dynamically to generate results, pages will load slow. 15 | 16 | ## Requirements ## 17 | 18 | - PHP >= 7 19 | - Laravel 5.3+ | 6 20 | 21 | ## Installation ## 22 | 23 | Install via composer 24 | 25 | ``` 26 | composer require sarfraznawaz2005/indexer --dev 27 | ``` 28 | 29 | For Laravel < 5.5: 30 | 31 | Add Service Provider to `config/app.php` in `providers` section 32 | ```php 33 | Sarfraznawaz2005\Indexer\ServiceProvider::class, 34 | ``` 35 | 36 | --- 37 | 38 | Publish package's config file by running below command: 39 | 40 | ```bash 41 | php artisan vendor:publish --provider="Sarfraznawaz2005\Indexer\ServiceProvider" 42 | ``` 43 | It should publish `config/indexer.php` config file. 44 | 45 | --- 46 | 47 | ## Screenshot ## 48 | 49 | When enabled, you will see yellow/green/red box on bottom right: 50 | 51 | - Yellow by default or when queries need to be optimized 52 | - Green when total queries count matches optimized queries count. 53 | - Red when one or more slow queries found and need to be optimized. 54 | 55 | ![Main Window](https://github.com/sarfraznawaz2005/indexer/blob/master/screenshot.jpg?raw=true) 56 | 57 | ## Config ## 58 | 59 | `enabled` : Enable or disable Indexer. By default it is disabled. 60 | 61 | `check_ajax_requests` : Specify whether to check queries in ajax requests. 62 | 63 | `ignore_tables` : When you don't use `watched_tables` option, Indexer watches all tables. Using this option, you can ignore specified tables to be watched. 64 | 65 | `ignore_paths` : These paths/patterns will NOT be handled by Indexer. 66 | 67 | `slow_time` : Time in ms when queries will be considered slow. 68 | 69 | `output_to` : Outputs results to given classes. By default `Web` class is included. 70 | 71 | `watched_tables` : DB tables to be watched by Indexer. Here is example: 72 | 73 | ````php 74 | 'watched_tables' => [ 75 | 'users' => [ 76 | // list of already existing indexes to try 77 | 'try_table_indexes' => ['email'], 78 | // new indexes to try 79 | 'try_indexes' => ['name'], 80 | // new composite indexes to try 81 | 'try_composite_indexes' => [ 82 | ['name', 'email'], 83 | ], 84 | ], 85 | ], 86 | ```` 87 | 88 | - Here queries involving `users` DB table will be watched by Indexer. 89 | - `try_table_indexes` contains index names that you have already applied to your DB table. Indexer will simply try out your existing indexes to show `EXPLAIN` results. In this case, `email` index already exists in `users` table. 90 | - `try_indexes` can be used to add new indexes on the fly to DB table. In this case, `name` index will be added on the fly by Indexer and results will be shown of how that index performed. 91 | - Like `try_indexes` the `try_composite_indexes` can also be used to add composite indexes on the fly to DB table. In this case, composite index consisting of `name` and `email` will be added on the fly by Indexer and results will be shown of how that index performed. 92 | 93 | 94 | ## Modes ## 95 | 96 | Indexer can be used in following ways: 97 | 98 | **All Indexes Added By Indexer** 99 | 100 | Don't put any indexes manually on your tables instead let Indexer add indexes on the fly via `try_indexes` and/or `try_composite_indexes` options. Indexes added via these two options are automatically removed. 101 | 102 | In this mode, you can actually see which indexes work best without actually applying on your tables. You can skip using `try_table_indexes` option in this case. 103 | 104 | **Already Present Indexes + Indexes Added By Indexer** 105 | 106 | You might have some indexes already present on your tables but you want to try out more indexes on the fly without actually adding those to the table. To specify table's existing indexes, use `try_table_indexes` option as mentioned earlier. And to try out new indexes on the fly, use `try_indexes` and/or `try_composite_indexes` options. Table's existing indexes (specified in `try_table_indexes`) will remain intact but indexes added via `try_indexes` and `try_composite_indexes` will be automatically removed. 107 | 108 | **Already Present Indexes** 109 | 110 | When you don't want Indexer to add any indexes on the fly and you have already specified indexes on your tables and you just want to see `EXPLAIN` results for specific tables for your indexes, in this case simply use `try_table_indexes` option only. Example: 111 | 112 | ````php 113 | 'watched_tables' => [ 114 | 'users' => [ 115 | 'try_table_indexes' => ['email'], 116 | ], 117 | 'posts' => [ 118 | 'try_table_indexes' => ['title'], 119 | ] 120 | ], 121 | ```` 122 | 123 | In this case, both `email` and `title` indexes are supposed to be already added to table manually. 124 | 125 | **No Indexes, Just Show EXPLAIN results for all SELECT queries** 126 | 127 | While previous three modes allow you to work with *specific tables and indexes*, you can use this mode to just show EXPLAIN results for all SELECT queries running on a page without adding any indexes on the fly. To use this mode, simply don't specify any tables in `watched_tables` option. If you don't want to include some tables in this mode, use `ignore_tables` option. 128 | 129 | ## Misc ## 130 | 131 | - Color of Indexer box on bottom right or query sections inside results changes to green if it finds query's `EXPLAIN` result has `key` present eg query actually used a key. This can be changed by creating your own function in your codebase called `indexerOptimizedKeyCustom(array $queries)` instead of default one `indexerOptimizedKey` which is present in file `src/Helpers.php`. Similarly, for ajax requests, you should define your own function called `indexerOptimizedKeyCustom(explain_result)`. Here is example of each: 132 | 133 | ````php 134 | // php 135 | function indexerOptimizedKeyCustom(array $query): string 136 | { 137 | return trim($query['explain_result']['key']); 138 | } 139 | ```` 140 | 141 | ````javascript 142 | // javascript 143 | function indexerOptimizedKeyCustom(explain_result) { 144 | return explain_result['key'] && explain_result['key'].trim(); 145 | } 146 | ```` 147 | 148 | *Note: If Indexer has found any slow queries (enabled via `slow_time` option), the color of box on bottom right will always be red until you fix slow queries.* 149 | 150 | ## Limitation ## 151 | 152 | * Indexer tries to find out tables names after `FROM` keyword in queries, therefore it cannot work with complex queries or ones that don't have table name after `FROM` keyword. 153 | 154 | ## Security 155 | 156 | If you discover any security related issues, please email sarfraznawaz2005@gmail.com instead of using the issue tracker. 157 | 158 | ## Credits 159 | 160 | - [Sarfraz Ahmed][link-author] 161 | - [All Contributors][link-contributors] 162 | 163 | ## License 164 | 165 | Please see the [license file](license.md) for more information. 166 | 167 | [ico-version]: https://img.shields.io/packagist/v/sarfraznawaz2005/indexer.svg?style=flat-square 168 | [ico-downloads]: https://img.shields.io/packagist/dt/sarfraznawaz2005/indexer.svg?style=flat-square 169 | 170 | [link-packagist]: https://packagist.org/packages/sarfraznawaz2005/indexer 171 | [link-downloads]: https://packagist.org/packages/sarfraznawaz2005/indexer 172 | [link-author]: https://github.com/sarfraznawaz2005 173 | [link-contributors]: https://github.com/sarfraznawaz2005/indexer/graphs/contributors 174 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sarfraznawaz2005/indexer", 3 | "keywords": [ 4 | "laravel", 5 | "database", 6 | "sql", 7 | "mysql", 8 | "index", 9 | "indexing", 10 | "eloquent", 11 | "querybuilder", 12 | "query", 13 | "queries", 14 | "performance", 15 | "speed", 16 | "boost", 17 | "optimization" 18 | ], 19 | "description": "Laravel package to monitor SELECT queries and offer best possible INDEX fields.", 20 | "type": "library", 21 | "license": "MIT", 22 | "authors": [ 23 | { 24 | "name": "Sarfraz Ahmed", 25 | "email": "sarfraznawaz2005@gmail.com" 26 | } 27 | ], 28 | "require": { 29 | "php": ">=7.0", 30 | "illuminate/support": "~5|~6|~7|~8", 31 | "doctrine/dbal": "^2" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Sarfraznawaz2005\\Indexer\\": "src/" 36 | }, 37 | "files": [ 38 | "src/Helpers.php" 39 | ] 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "Sarfraznawaz2005\\Indexer\\ServiceProvider" 45 | ] 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sarfraz Ahmed 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 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarfraznawaz2005/indexer/aa7ec09fa2cafae9ff62a21c9559711839c4f74e/screenshot.jpg -------------------------------------------------------------------------------- /src/Config/config.php: -------------------------------------------------------------------------------- 1 | env('INDEXER_ENABLED', false), 9 | 10 | /* 11 | * Specify whether to check queries in ajax requests. 12 | */ 13 | 'check_ajax_requests' => true, 14 | 15 | /* 16 | * These tables will be watched by Indexer and specified indexes will be tested. 17 | */ 18 | 'watched_tables' => [ 19 | /* 20 | 'users' => [ 21 | // list of already existing indexes to try 22 | 'try_table_indexes' => ['email'], 23 | // new indexes indexes to try 24 | 'try_indexes' => ['name'], 25 | // composite indexes to try 26 | 'try_composite_indexes' => [ 27 | ['name', 'email'], 28 | ], 29 | ], 30 | */ 31 | ], 32 | 33 | /* 34 | * When you don't use "watched_tables" option, Indexer watches all tables. 35 | * Using this option, you can ignore specified tables to be watched. 36 | */ 37 | 'ignore_tables' => [ 38 | // 39 | ], 40 | 41 | /* 42 | * These paths/patterns will NOT be handled by Indexer. 43 | */ 44 | 'ignore_paths' => [ 45 | // 'foo', 46 | // 'foo*', 47 | // '*foo', 48 | // '*foo*', 49 | ], 50 | 51 | /* 52 | * Time in ms when queries will be considered slow (>=). A slow query will 53 | * be highlighted with red color. Value of 0 means no color change. 54 | */ 55 | 'slow_time' => 0, 56 | 57 | /* 58 | * Outputs results class. 59 | */ 60 | 'output_to' => [ 61 | // outputs results into current visited page. 62 | Sarfraznawaz2005\Indexer\Outputs\Web::class, 63 | ], 64 | 65 | /* 66 | * Font size (including unit) in case of Web output class 67 | */ 68 | 'font_size' => '12px', 69 | ]; 70 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | "; 82 | $output .= "
"; 83 | $output .= "
$query[title]
"; 84 | $output .= "
$query[time] ms
"; 85 | $output .= "
"; 86 | $output .= '
'; 87 | $output .= "
"; 88 | $output .= "File: $query[file]
"; 89 | $output .= "Line: $query[line]"; 90 | $output .= '
'; 91 | $output .= '
' . $query['sql'] . '
'; 92 | $output .= indexerTable([$query['explain_result']]); 93 | 94 | if ($query['hints']) { 95 | $output .= "
"; 96 | 97 | foreach ($query['hints'] as $hint) { 98 | $output .= "Hint $hint
"; 99 | } 100 | 101 | $output .= '
'; 102 | } 103 | 104 | $output .= ''; 105 | } 106 | 107 | return $output; 108 | } 109 | } 110 | 111 | if (!function_exists('indexerTable')) { 112 | /** 113 | * Generates HTML table. 114 | * 115 | * @param $array 116 | * @param bool $table 117 | * @return string 118 | */ 119 | function indexerTable($array, $table = true): string 120 | { 121 | $out = ''; 122 | 123 | foreach ($array as $key => $value) { 124 | if (is_array($value)) { 125 | if (!isset($tableHeader)) { 126 | $tableHeader = '' . implode('', array_keys($value)) . ''; 127 | } 128 | 129 | array_keys($value); 130 | 131 | $out .= ''; 132 | $out .= indexerTable($value, false); 133 | $out .= ''; 134 | } else { 135 | $out .= "$value"; 136 | } 137 | } 138 | 139 | if ($table) { 140 | return '' . $tableHeader . $out . '
'; 141 | } 142 | 143 | return $out; 144 | } 145 | } 146 | 147 | -------------------------------------------------------------------------------- /src/Indexer.php: -------------------------------------------------------------------------------- 1 | isEnabled()) { 42 | return false; 43 | } 44 | 45 | $this->tables = DB::connection()->getDoctrineSchemaManager()->listTableNames(); 46 | 47 | app()->events->listen(QueryExecuted::class, [$this, 'analyzeQuery']); 48 | 49 | foreach ($this->getOutputTypes() as $outputType) { 50 | app()->singleton($outputType); 51 | app($outputType)->boot(); 52 | } 53 | } 54 | 55 | /** 56 | * @return bool 57 | */ 58 | public function isEnabled(): bool 59 | { 60 | return config('indexer.enabled', false); 61 | } 62 | 63 | /** 64 | * Enable Indexer 65 | */ 66 | public function enableDetection() 67 | { 68 | DB::enableQueryLog(); 69 | 70 | $this->detectQueries = true; 71 | } 72 | 73 | /** 74 | * Disable Indexer 75 | */ 76 | public function disableDetection() 77 | { 78 | DB::disableQueryLog(); 79 | 80 | $this->detectQueries = false; 81 | } 82 | 83 | /** 84 | * Checks if Indexer is watching queries. 85 | * 86 | * @return bool 87 | */ 88 | public function isDetecting(): bool 89 | { 90 | return $this->detectQueries; 91 | } 92 | 93 | /** 94 | * Starts indexing process. 95 | * 96 | * @param QueryExecuted $event 97 | */ 98 | public function analyzeQuery(QueryExecuted $event) 99 | { 100 | if (!$this->isDetecting()) { 101 | return; 102 | } 103 | 104 | $this->queryEvent = $event; 105 | $this->table = $this->getTableNameFromQuery(); 106 | $this->source = $this->getCallerFromStackTrace(); 107 | //dump($this->table); 108 | 109 | if ($this->isSelectQuery() && $this->isValidTable()) { 110 | $this->disableDetection(); 111 | $this->tryIndexes(); 112 | $this->enableDetection(); 113 | } else { 114 | $this->skippedQueries = $this->getSql(); 115 | } 116 | } 117 | 118 | /** 119 | * Get current SQL query. 120 | * 121 | * @return string 122 | */ 123 | protected function getSql(): string 124 | { 125 | return $this->replaceBindings($this->queryEvent); 126 | } 127 | 128 | /** 129 | * Gets current query model name. 130 | * 131 | * @return string 132 | */ 133 | protected function getModelName(): string 134 | { 135 | $backtrace = collect(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 50)); 136 | 137 | $modelTrace = $backtrace->first(static function ($trace) { 138 | return Arr::get($trace, 'object') instanceof Builder; 139 | }); 140 | 141 | return $modelTrace ? get_class($modelTrace['object']->getModel()) : ''; 142 | } 143 | 144 | /** 145 | * @return bool 146 | */ 147 | protected function isSelectQuery(): bool 148 | { 149 | return strtolower(strtok($this->getSql(), ' ')) === 'select'; 150 | } 151 | 152 | /** 153 | * @return string 154 | */ 155 | protected function getTableNameFromQuery(): string 156 | { 157 | $table = trim(str_ireplace(['from', '`'], '', stristr($this->getSql(), 'from'))); 158 | $table = strtok($table, ' '); 159 | 160 | return preg_replace('/\v(?:[\v\h]+)/', '', $table); 161 | } 162 | 163 | /** 164 | * See if table we found exists in DB 165 | * 166 | * @return bool 167 | */ 168 | protected function isValidTable(): bool 169 | { 170 | return in_array($this->table, $this->tables, true); 171 | } 172 | 173 | /** 174 | * Tries applying given indexes. 175 | */ 176 | protected function tryIndexes() 177 | { 178 | $indexes = []; 179 | $addedIndexes = []; 180 | $tableOriginalIndexes = $this->getTableOriginalIndexes(); 181 | 182 | $table = $this->table; 183 | 184 | try { 185 | 186 | if (config('indexer.watched_tables', [])) { 187 | if (array_key_exists($table, config('indexer.watched_tables', []))) { 188 | 189 | $tableIndexeOptions = config("indexer.watched_tables.$table", []); 190 | 191 | $tableIndexes = $tableIndexeOptions['try_table_indexes'] ?? []; 192 | $newIndexes = $tableIndexeOptions['try_indexes'] ?? []; 193 | $compositeIndexes = $tableIndexeOptions['try_composite_indexes'] ?? []; 194 | 195 | $indexes = array_merge($tableIndexes, $newIndexes, $compositeIndexes); 196 | 197 | $addedIndexes = $this->applyIndexes($indexes); 198 | 199 | $this->removeUserDefinedIndexes($addedIndexes); 200 | } else { 201 | $this->skippedTables[] = $table; 202 | } 203 | 204 | } else { 205 | // just run EXPLAIN on all SELECT queries running on the page 206 | if (!in_array($table, config('indexer.ignore_tables', []), true)) { 207 | $this->applyIndexes([]); 208 | } else { 209 | $this->skippedTables[] = $table; 210 | } 211 | } 212 | 213 | } catch (Exception $e) { 214 | dump('Indexer Error: ' . $e->getLine() . ' - ' . $e->getMessage()); 215 | } finally { 216 | // just in case - again remove any custom added indexes 217 | $this->removeUserDefinedIndexes($addedIndexes); 218 | 219 | // make sure we have deleted added indexes 220 | if ($indexes) { 221 | $this->unremovedIndexes = $this->checkAnyUnremovedIndexes($tableOriginalIndexes); 222 | } 223 | } 224 | } 225 | 226 | /** 227 | * Gets already applied indexes on the table. 228 | * 229 | * @return array 230 | */ 231 | protected function getTableOriginalIndexes(): array 232 | { 233 | $table = $this->table; 234 | 235 | return collect(DB::select("SHOW INDEXES FROM $table"))->pluck('Key_name')->toArray(); 236 | } 237 | 238 | /** 239 | * Checks if we have any un-removed indexes after applying indexes. 240 | * 241 | * @param $tableOriginalIndexes 242 | * @return array 243 | */ 244 | protected function checkAnyUnremovedIndexes($tableOriginalIndexes): array 245 | { 246 | $tableOriginalIndexesAfter = $this->getTableOriginalIndexes(); 247 | 248 | if ($tableOriginalIndexes !== $tableOriginalIndexesAfter) { 249 | return array_diff($tableOriginalIndexesAfter, $tableOriginalIndexes); 250 | } 251 | 252 | return []; 253 | } 254 | 255 | /** 256 | * Applies given indexes to the table, builds EXPLAIN query and then removes added indexes. 257 | * 258 | * @param array $indexes 259 | * @return array 260 | */ 261 | protected function applyIndexes(array $indexes): array 262 | { 263 | $addedIndexes = []; 264 | 265 | if ($indexes) { 266 | foreach ($indexes as $index) { 267 | 268 | $indexName = is_array($index) ? $this->getLaravelIndexName($index) : $index; 269 | $key = $this->makeKey($indexName . $this->getSql()); 270 | 271 | // don't do anything if we have already checked this query 272 | if (array_key_exists($key, $this->queries)) { 273 | continue; 274 | } 275 | 276 | if (!$this->indexExists($index)) { 277 | $addedIndexes[] = $index; 278 | 279 | $this->addIndex($index); 280 | $this->explainQuery($index, false); 281 | $this->removeIndex($index); 282 | 283 | } else { 284 | $this->explainQuery($index); 285 | } 286 | } 287 | } else { 288 | $this->explainQuery(); 289 | } 290 | 291 | return $addedIndexes; 292 | } 293 | 294 | /** 295 | * @param $index 296 | * @return bool 297 | */ 298 | protected function indexExists($index): bool 299 | { 300 | $table = $this->table; 301 | 302 | $indexes = collect(DB::select("SHOW INDEXES FROM $table"))->pluck('Key_name')->toArray(); 303 | 304 | if (!in_array($index, $indexes, true)) { 305 | $index = $this->getLaravelIndexName($index); 306 | } 307 | 308 | return in_array($index, $indexes, true); 309 | } 310 | 311 | /** 312 | * @param $index 313 | */ 314 | protected function addIndex($index) 315 | { 316 | $table = $this->table; 317 | 318 | try { 319 | Schema::table($table, static function (Blueprint $table) use ($index) { 320 | $table->index($index); 321 | }); 322 | } catch (Exception $e) { 323 | } 324 | } 325 | 326 | /** 327 | * @param $index 328 | */ 329 | protected function removeIndex($index) 330 | { 331 | $table = $this->table; 332 | 333 | try { 334 | Schema::table($table, static function (Blueprint $table) use ($index) { 335 | is_array($index) ? $table->dropIndex([$index]) : $table->dropIndex($index); 336 | }); 337 | } catch (Exception $e) { 338 | try { 339 | Schema::table($table, function (Blueprint $table) use ($index) { 340 | $table->dropIndex($this->getLaravelIndexName($index)); 341 | }); 342 | } catch (Exception $e) { 343 | } 344 | } 345 | } 346 | 347 | /** 348 | * Removes indexes given in config eg not ones already applied on the table. 349 | * 350 | * @param $addedIndexes 351 | */ 352 | protected function removeUserDefinedIndexes($addedIndexes) 353 | { 354 | foreach ($addedIndexes as $index) { 355 | $this->removeIndex($index); 356 | } 357 | } 358 | 359 | /** 360 | * Collects EXPLAIN info and stores in queries var. 361 | * 362 | * @param $index 363 | * @param bool $isIndexAlreadyPresentOnTable 364 | */ 365 | protected function explainQuery($index = null, $isIndexAlreadyPresentOnTable = true) 366 | { 367 | $event = $this->queryEvent; 368 | $sql = $this->getSql(); 369 | $hints = $this->performQueryAnalysis($sql); 370 | $isSlow = false; 371 | 372 | $indexName = $title = $this->table; 373 | 374 | if ($index) { 375 | $indexType = $isIndexAlreadyPresentOnTable ? 'Already Present On Table' : 'Added By Indexer'; 376 | $indexName = is_array($index) ? $this->getLaravelIndexName($index) : $index; 377 | $title = "{$this->table} → $indexName ($indexType)"; 378 | } 379 | 380 | $key = $this->makeKey($indexName . $sql); 381 | 382 | $result = DB::select(DB::raw('EXPLAIN ' . $sql))[0] ?? null; 383 | 384 | if ($result) { 385 | $queryResult['explain_result'] = (array)$result; 386 | $queryResult['sql'] = $sql; 387 | $queryResult['time'] = number_format($event->time, 2); 388 | $queryResult['title'] = $title; 389 | $queryResult['index_name'] = $indexName; 390 | $queryResult['file'] = $this->source['file']; 391 | $queryResult['line'] = $this->source['line']; 392 | $queryResult['hints'] = $hints; 393 | $queryResult['url'] = \request()->fullUrl(); 394 | 395 | if ($configTime = config('indexer.slow_time', 0)) { 396 | $isSlow = $queryResult['time'] >= $configTime; 397 | } 398 | 399 | $queryResult['slow'] = $isSlow; 400 | $queryResult['skippedTables'] = $this->skippedTables; 401 | 402 | // remove any chars that cause problem in JSON response 403 | $queryResult = $this->arrayReplace($queryResult, ':', ''); 404 | 405 | $this->queries[$key] = $queryResult; 406 | } 407 | } 408 | 409 | /** 410 | * Makes queries array keys so we don't analyze same query again. 411 | * 412 | * @param $indexName 413 | * @return string 414 | */ 415 | protected function makeKey($indexName): string 416 | { 417 | return md5($this->getLaravelIndexName($indexName) . $this->getSql()); 418 | } 419 | 420 | /** 421 | * Replaces chars in nested array. 422 | * 423 | * @param $array 424 | * @param $find 425 | * @param $replace 426 | * @return array 427 | */ 428 | protected function arrayReplace($array, $find, $replace): array 429 | { 430 | if (is_array($array)) { 431 | foreach ($array as $key => $val) { 432 | if (is_array($array[$key])) { 433 | $array[$key] = $this->arrayReplace($array[$key], $find, $replace); 434 | } else { 435 | $array[$key] = str_ireplace($find, $replace, $array[$key]); 436 | } 437 | } 438 | } 439 | 440 | return $array; 441 | } 442 | 443 | /** 444 | * Explainer::performQueryAnalysis() 445 | * 446 | * Perform simple regex analysis on the code 447 | * 448 | * @package xplain (https://github.com/rap2hpoutre/mysql-xplain-xplain) 449 | * @author e-doceo 450 | * @copyright 2014 451 | * @version $Id$ 452 | * @access public 453 | * @param string $query 454 | * @return array 455 | */ 456 | protected function performQueryAnalysis($query): array 457 | { 458 | $hints = []; 459 | 460 | if (preg_match('/^\\s*SELECT\\s*`?[a-zA-Z0-9]*`?\\.?\\*/i', $query)) { 461 | $hints[] = 'Use SELECT * only if you need all columns from table'; 462 | } 463 | 464 | if (preg_match('/ORDER BY RAND()/i', $query)) { 465 | $hints[] = 'ORDER BY RAND() is slow, try to avoid if you can. You can read this or this'; 466 | } 467 | 468 | if (strpos($query, '!=') !== false) { 469 | $hints[] = 'The != operator is not standard. Use the <> operator to test for inequality instead.'; 470 | } 471 | 472 | if (stripos($query, 'WHERE') === false && preg_match('/^(SELECT) /i', $query)) { 473 | $hints[] = 'The SELECT statement has no WHERE clause and could examine many more rows than intended'; 474 | } 475 | 476 | if (preg_match('/LIMIT\\s/i', $query) && stripos($query, 'ORDER BY') === false) { 477 | $hints[] = 'LIMIT without ORDER BY causes non-deterministic results, depending on the query execution plan'; 478 | } 479 | 480 | if (preg_match('/LIKE\\s[\'"](%.*?)[\'"]/i', $query, $matches)) { 481 | $hints[] = 'An argument has a leading wildcard character: ' . $matches[1] . '. The predicate with this argument is not sargable and cannot use an index if one exists.'; 482 | } 483 | 484 | return $hints; 485 | } 486 | 487 | /** 488 | * Gets composite indexes name based on how Laravel makes those names by default. 489 | * 490 | * @param string|array $index 491 | * @return string 492 | */ 493 | protected function getLaravelIndexName($index): string 494 | { 495 | $name[] = $this->table; 496 | 497 | if (!is_array($index)) { 498 | $name[] = $index; 499 | } else { 500 | foreach ($index as $indexItem) { 501 | $name[] = $indexItem; 502 | } 503 | } 504 | 505 | $name[] = 'index'; 506 | 507 | return strtolower(implode('_', $name)); 508 | } 509 | 510 | /** 511 | * Replace the placeholders with the actual bindings. 512 | * 513 | * @param QueryExecuted $event 514 | * @return string 515 | */ 516 | protected function replaceBindings($event): string 517 | { 518 | $sql = $event->sql; 519 | 520 | foreach ($this->formatBindings($event) as $key => $binding) { 521 | $regex = is_numeric($key) 522 | ? "/\?(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/" 523 | : "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/"; 524 | 525 | if ($binding === null) { 526 | $binding = 'null'; 527 | } elseif (!is_int($binding) && !is_float($binding)) { 528 | $binding = $event->connection->getPdo()->quote($binding); 529 | } 530 | 531 | $sql = preg_replace($regex, $binding, $sql, 1); 532 | } 533 | 534 | return $sql; 535 | } 536 | 537 | /** 538 | * Format the given bindings to strings. 539 | * 540 | * @param QueryExecuted $event 541 | * @return array 542 | */ 543 | protected function formatBindings($event): array 544 | { 545 | return $event->connection->prepareBindings($event->bindings); 546 | } 547 | 548 | /** 549 | * @return array|Repository|mixed 550 | */ 551 | protected function getOutputTypes() 552 | { 553 | $outputTypes = config('indexer.output_to', [Web::class]); 554 | 555 | if (!is_array($outputTypes)) { 556 | $outputTypes = [$outputTypes]; 557 | } 558 | 559 | return $outputTypes; 560 | } 561 | 562 | /** 563 | * Applies output. 564 | * 565 | * @param Request $request 566 | * @param \Symfony\Component\HttpFoundation\Response $response 567 | */ 568 | protected function applyOutput(Request $request, \Symfony\Component\HttpFoundation\Response $response) 569 | { 570 | // sort by time descending 571 | $this->queries = collect($this->queries)->sortByDesc('time')->all(); 572 | 573 | foreach ($this->getOutputTypes() as $type) { 574 | app($type)->output($this->queries, $request, $response); 575 | } 576 | } 577 | 578 | /** 579 | * Outputs results. 580 | * 581 | * @param Request $request 582 | * @param $response 583 | * @return Response|void 584 | */ 585 | public function outputResults(Request $request, $response) 586 | { 587 | $this->applyOutput($request, $response); 588 | 589 | return $response; 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /src/IndexerMiddleware.php: -------------------------------------------------------------------------------- 1 | indexer = $indexer; 17 | } 18 | 19 | /** 20 | * Handle an incoming request. 21 | * 22 | * @param Request $request 23 | * @param Closure $next 24 | * @return mixed 25 | */ 26 | public function handle($request, Closure $next) 27 | { 28 | if (!$this->canSendResponse($request)) { 29 | return $next($request); 30 | } 31 | 32 | $this->indexer->boot(); 33 | 34 | $response = $next($request); 35 | 36 | $this->indexer->outputResults($request, $response); 37 | 38 | return $response; 39 | } 40 | 41 | /** 42 | * See if we can add indexer results to response. 43 | * 44 | * @param Request $request 45 | * @param $response 46 | * @return bool 47 | */ 48 | protected function canSendResponse(Request $request): bool 49 | { 50 | if (!$this->indexer->isEnabled()) { 51 | return false; 52 | } 53 | 54 | if (!$request->isMethod('get')) { 55 | if (!$request->ajax() && !$request->expectsJson()) { 56 | return false; 57 | } 58 | } 59 | 60 | if (!$this->isAllowedRequest()) { 61 | return false; 62 | } 63 | 64 | return true; 65 | } 66 | 67 | /** 68 | * Check if we are handling allowed request/page. 69 | * 70 | * @return bool 71 | */ 72 | protected function isAllowedRequest(): bool 73 | { 74 | $ignoredPaths = array_merge([ 75 | '*indexer*', 76 | '*meter*', 77 | '*debugbar*', 78 | '*_debugbar*', 79 | '*clockwork*', 80 | '*_clockwork*', 81 | '*telescope*', 82 | '*horizon*', 83 | '*vendor/horizon*', 84 | '*nova-api*', 85 | ], config('indexer.ignore_paths', [])); 86 | 87 | return !app()->request->is($ignoredPaths); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Outputs/Output.php: -------------------------------------------------------------------------------- 1 | headers->get('Content-Type'), 'text/html') !== 0) { 19 | //return; 20 | } 21 | 22 | if ($response instanceof BinaryFileResponse) { 23 | return; 24 | } 25 | 26 | if ($response->isRedirection()) { 27 | return; 28 | } 29 | 30 | if (app()->runningInConsole()) { 31 | return; 32 | } 33 | 34 | if (!$request->acceptsHtml()) { 35 | return; 36 | } 37 | 38 | if ($request->ajax() || $request->expectsJson()) { 39 | 40 | if ($queries && config('indexer.check_ajax_requests', false)) { 41 | $response->headers->set('indexer_ajax_response', json_encode($queries)); 42 | } 43 | 44 | return; 45 | } 46 | 47 | $content = $response->getContent(); 48 | $outputContent = $this->getOutputContent($queries); 49 | $position = strripos($content, ''); 50 | 51 | if (false !== $position) { 52 | $content = substr($content, 0, $position) . $outputContent . substr($content, $position); 53 | } else { 54 | $content .= $outputContent; 55 | } 56 | 57 | // Update the new content and reset the content length 58 | $response->setContent($content); 59 | 60 | $response->headers->remove('Content-Length'); 61 | } 62 | 63 | /** 64 | * Sends output 65 | * 66 | * @param array $queries 67 | * @return string 68 | */ 69 | protected function getOutputContent(array $queries): string 70 | { 71 | $output = ''; 72 | 73 | $fontSize = config('indexer.font_size', '12px'); 74 | 75 | $colorYellow = '#fff382'; 76 | $colorGreen = '#91e27f'; 77 | $colorRed = '#ff7a94'; 78 | 79 | $totalCount = count($queries); 80 | $slowCount = (int)indexerGetSlowCount($queries); 81 | $optimizedCount = (int)indexerGetOptimizedCount($queries); 82 | 83 | // default color 84 | $indexerColor = $colorYellow; 85 | 86 | // when all queries are optimized 87 | if ($optimizedCount === $totalCount) { 88 | $indexerColor = $colorGreen; 89 | } 90 | 91 | // when one or more slow queries found 92 | if ($slowCount) { 93 | $indexerColor = $colorRed; 94 | } 95 | 96 | $output .= <<< OUTOUT 97 | 128 | OUTOUT; 129 | 130 | 131 | $output .= 'INDEXER ' . $optimizedCount . '/' . $totalCount . ''; 132 | $output .= ''; 133 | $output .= ''; 145 | } 146 | } else { 147 | $output .= '
Nothing Yet :(
'; 148 | } 149 | 150 | $output .= ''; 151 | 152 | $output .= <<< OUTOUT 153 | 154 | 184 | OUTOUT; 185 | 186 | if (config('indexer.check_ajax_requests', false)) { 187 | $output .= <<< OUTOUT 188 | 189 | 327 | 328 | OUTOUT; 329 | } 330 | 331 | $output = "\n\n" . $this->compress($output) . "\n\n\n"; 332 | 333 | return $output; 334 | } 335 | 336 | /** 337 | * Compress indexer response 338 | * 339 | * @param $html 340 | * @return string 341 | */ 342 | private function compress($html): string 343 | { 344 | ini_set('pcre.recursion_limit', '16777'); 345 | 346 | $regEx = '%# Collapse whitespace everywhere but in blacklisted elements. 347 | (?> # Match all whitespans other than single space. 348 | [^\S ]\s* # Either one [\t\r\n\f\v] and zero or more ws, 349 | | \s{2,} # or two or more consecutive-any-whitespace. 350 | ) # Note: The remaining regex consumes no text at all... 351 | (?= # Ensure we are not in a blacklist tag. 352 | [^<]*+ # Either zero or more non-"<" {normal*} 353 | (?: # Begin {(special normal*)*} construct 354 | < # or a < starting a non-blacklist tag. 355 | (?!/?(?:textarea|pre)\b) 356 | [^<]*+ # more non-"<" {normal*} 357 | )*+ # Finish "unrolling-the-loop" 358 | (?: # Begin alternation group. 359 | < # Either a blacklist start tag. 360 | (?>textarea|pre)\b 361 | | \z # or end of file. 362 | ) # End alternation group. 363 | ) # If we made it here, we are not in a blacklist tag. 364 | %Six'; 365 | 366 | $compressed = preg_replace($regEx, ' ', $html); 367 | 368 | if ($compressed !== null) { 369 | $html = $compressed; 370 | } 371 | 372 | return trim($html); 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->publishes([ 14 | __DIR__ . '/Config/config.php' => config_path('indexer.php'), 15 | ], 'config'); 16 | } 17 | 18 | $this->registerMiddleware(IndexerMiddleware::class); 19 | } 20 | 21 | public function register() 22 | { 23 | $this->app->singleton(Indexer::class); 24 | 25 | $this->mergeConfigFrom(__DIR__ . '/Config/config.php', 'indexer'); 26 | } 27 | 28 | /** 29 | * Register the middleware 30 | * 31 | * @param string $middleware 32 | */ 33 | protected function registerMiddleware($middleware) 34 | { 35 | $kernel = $this->app[Kernel::class]; 36 | $kernel->pushMiddleware($middleware); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Traits/FetchesStackTrace.php: -------------------------------------------------------------------------------- 1 | forget(0); 17 | 18 | return $trace->first(function ($frame) { 19 | if (!isset($frame['file'])) { 20 | return false; 21 | } 22 | 23 | return !Str::contains($frame['file'], 24 | base_path('vendor' . DIRECTORY_SEPARATOR . $this->ignoredVendorPath()) 25 | ); 26 | }); 27 | } 28 | 29 | /** 30 | * Choose the frame outside of either Telescope/Laravel or all packages. 31 | * 32 | * @return string|null 33 | */ 34 | protected function ignoredVendorPath() 35 | { 36 | if (!($this->options['ignore_packages'] ?? true)) { 37 | return 'laravel'; 38 | } 39 | } 40 | } 41 | --------------------------------------------------------------------------------