diff --git a/composer.json b/composer.json index 2274ac8..0d375b4 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ ], "require": { "ext-json": "*", + "ext-pdo": "*", "guanguans/soar-php": "^2.5", "illuminate/console": "^6.10 || ^7.0 || ^8.0 || ^9.0", "illuminate/contracts": "^6.10 || ^7.0 || ^8.0 || ^9.0", diff --git a/src/Support/QueryAnalysis.php b/src/Support/QueryAnalysis.php new file mode 100644 index 0000000..13bbace --- /dev/null +++ b/src/Support/QueryAnalysis.php @@ -0,0 +1,165 @@ + + * + * This source file is subject to the MIT license that is bundled. + */ + +namespace Guanguans\LaravelSoar\Support; + +use DateTime; +use PDO; + +class QueryAnalysis +{ + /** + * KEYWORDS1. + * + * @var string + */ + public const KEYWORDS1 = 'SELECT|(?:ON\s+DUPLICATE\s+KEY)?UPDATE|INSERT(?:\s+INTO)?|REPLACE(?:\s+INTO)?|DELETE|CALL|UNION|FROM|WHERE|HAVING|GROUP\s+BY|ORDER\s+BY|LIMIT|OFFSET|SET|VALUES|LEFT\s+JOIN|INNER\s+JOIN|TRUNCATE'; + + /** + * KEYWORDS2. + * + * @var string + */ + public const KEYWORDS2 = 'ALL|DISTINCT|DISTINCTROW|IGNORE|AS|USING|ON|AND|OR|IN|IS|NOT|NULL|[RI]?LIKE|REGEXP|TRUE|FALSE'; + + /** + * Returns syntax highlighted SQL command. + * + * @param string $sql + * @param \PDO $pdo + * + * @return string + */ + public static function highlight($sql, array $bindings = [], PDO $pdo = null) + { + // insert new lines + $sql = " $sql "; + $sql = preg_replace('#(?<=[\\s,(])('.static::KEYWORDS1.')(?=[\\s,)])#i', "\n\$1", $sql); + + // reduce spaces + $sql = preg_replace('#[ \t]{2,}#', ' ', $sql); + + // syntax highlight + $sql = htmlspecialchars($sql, ENT_IGNORE, 'UTF-8'); + $sql = preg_replace_callback('#(/\\*.+?\\*/)|(\\*\\*.+?\\*\\*)|(?<=[\\s,(])('.static::KEYWORDS1.')(?=[\\s,)])|(?<=[\\s,(=])('.static::KEYWORDS2.')(?=[\\s,)=])#is', function ($matches) { + if (! empty($matches[1])) { // comment + return ''.$matches[1].''; + } elseif (! empty($matches[2])) { // error + return ''.$matches[2].''; + } elseif (! empty($matches[3])) { // most important keywords + return ''.$matches[3].''; + } elseif (! empty($matches[4])) { // other keywords + return ''.$matches[4].''; + } + }, $sql); + + $bindings = array_map(function ($binding) use ($pdo) { + if (true === is_array($binding)) { + $binding = implode(', ', array_map(function ($value) { + return true === is_string($value) ? htmlspecialchars('\''.$value.'\'', ENT_NOQUOTES, 'UTF-8') : $value; + }, $binding)); + + return htmlspecialchars('('.$binding.')', ENT_NOQUOTES, 'UTF-8'); + } + + if (true === is_string($binding) && (preg_match('#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{10FFFF}]#u', $binding) || preg_last_error())) { + return '<binary>'; + } + + if (true === is_string($binding)) { + $text = htmlspecialchars($pdo ? $pdo->quote($binding) : '\''.$binding.'\'', ENT_NOQUOTES, 'UTF-8'); + + return ''.$text.''; + } + + if (true === is_resource($binding)) { + $type = get_resource_type($binding); + if ('stream' === $type) { + $info = stream_get_meta_data($binding); + } + + return '<'.htmlspecialchars($type, ENT_NOQUOTES, 'UTF-8').' resource>'; + } + + if ($binding instanceof DateTime) { + return htmlspecialchars('\''.$binding->format('Y-m-d H:i:s').'\'', ENT_NOQUOTES, 'UTF-8'); + } + + return htmlspecialchars($binding, ENT_NOQUOTES, 'UTF-8'); + }, $bindings); + $sql = str_replace(['%', '?'], ['%%', '%s'], $sql); + + return '
'.nl2br(trim(vsprintf($sql, $bindings))).'
SELECT *
only if you need all columns from table';
+ }
+ if (preg_match('/ORDER BY RAND()/i', $sql)) {
+ $hints[] = 'ORDER BY RAND()
is slow, try to avoid if you can.
+ You can read this
+ or this';
+ }
+ if (false !== strpos($sql, '!=')) {
+ $hints[] = 'The !=
operator is not standard. Use the <>
operator to test for inequality instead.';
+ }
+ if (false === stripos($sql, 'WHERE')) {
+ $hints[] = 'The SELECT
statement has no WHERE
clause and could examine many more rows than intended';
+ }
+ if (preg_match('/LIMIT\\s/i', $sql) && false === stripos($sql, 'ORDER BY')) {
+ $hints[] = 'LIMIT
without ORDER BY
causes non-deterministic results, depending on the query execution plan';
+ }
+ if (preg_match('/LIKE\\s[\'"](%.*?)[\'"]/i', $sql, $matches)) {
+ $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.';
+ }
+ if ($version < 5.5 && 'mysql' === $driver) {
+ if (preg_match('/\\sIN\\s*\\(\\s*SELECT/i', $sql)) {
+ $hints[] = 'IN()
and NOT IN()
subqueries are poorly optimized in that MySQL version : '.$version.
+ '. MySQL executes the subquery as a dependent subquery for each row in the outer query';
+ }
+ }
+
+ return $hints;
+ }
+
+ /**
+ * explain sql.
+ *
+ * @param string $sql
+ * @param array $bindings
+ *
+ * @return array
+ */
+ public static function explain(PDO $pdo, $sql, $bindings = [])
+ {
+ $explains = [];
+ if (preg_match('#\s*\(?\s*SELECT\s#iA', $sql)) {
+ $statement = $pdo->prepare('EXPLAIN '.$sql);
+ $statement->execute($bindings);
+ $explains = $statement->fetchAll(PDO::FETCH_CLASS);
+ }
+
+ return $explains;
+ }
+}