【Laravel】 QueryException で表示される SQL を正しくクオートする

# 問題

Laravel におけるデータベース操作でクエリエラーが発生したとき， `PDOException` はキャッチされて [`QueryException`](https://github.com/laravel/framework/blob/f3d7cf0cd3aecbb3ba922ce2f0724b77bdf8517e/src/Illuminate/Database/QueryException.php) に変換される。これは生成時に

- 元のプレースホルダ `?` 入りのSQL文
- バインドパラメータの配列

を受け取り，メッセージに `?` を実際の値に置換したSQLをセットしてくれる機能を持っている。しかし，整数値以外は正常に表示されない。クオート処理が行われていないためである。

```php
\DB::statement('INSERT INTO invalid_grammar(?, ?, ?)', [1, null, 'xxx']);
```

> Illuminate/Database/QueryException with message 'SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '1, NULL, 'xxx')' at line 1 (SQL: INSERT INTO invalid_grammar(1, , xxx))'

`INSERT INTO invalid_grammar(1, , xxx)`
↓
`INSERT INTO invalid_grammar(1, null, 'xxx')`

こうなってほしい！

# 解決策

いつものようにコネクションクラスの継承をやっちゃいます。

[【Laravel】 MySQL がマスタスレーブ構成のとき，リクエストを超えて sticky 効果を適用する - Qiita](https://qiita.com/mpyw/items/8b7ab3311ce3f08298d7)

この辺でもすでにやってるけど一応今回も載せておきます。

## 実装

```php:app/Database/MySqlConnection.php
<?php

declare(strict_types=1);

namespace App\Database;

use Illuminate\Database\MySqlConnection as BaseMySqlConnection;

/**
 * Class MySqlConnection
 */
class MySqlConnection extends BaseMySqlConnection
{
    /**
     * Run a SQL statement.
     *
     * @param  string                       $query
     * @param  array                        $bindings
     * @param  \Closure                     $callback
     * @throws \App\Database\QueryException
     * @return mixed
     */
    protected function runQueryCallback($query, $bindings, Closure $callback)
    {
        try {
            $result = $callback($query, $bindings);
        } catch (\Exception $e) {
            throw new QueryException(
                $query, $this->prepareBindings($bindings), $e, $this->getPdo()
            );
        }
        return $result;
    }
}
```

```php:app/Providers/DatabaseServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Database\MySqlConnection;
use Illuminate\Database\Connection;
use Illuminate\Support\ServiceProvider;

class DatabaseServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Connection::resolverFor('mysql', function (...$parameters) {
            return new MySqlConnection(...$parameters);
        });
    }
}
```

```php:app/Database/QueryException.php
<?php

declare(strict_types=1);

namespace App\Database;

use Illuminate\Database\QueryException as BaseQueryException;
use Illuminate\Support\Str;

class QueryException extends BaseQueryException
{
    /**
     * @var \PDO
     */
    protected $pdo;

    /**
     * QueryException constructor.
     *
     * @param string     $sql
     * @param array      $bindings
     * @param \Exception $previous
     * @param \PDO       $pdo
     */
    public function __construct(string $sql, array $bindings, \Exception $previous, \PDO $pdo)
    {
        $this->pdo = $pdo;

        parent::__construct($sql, $bindings, $previous);
    }

    /**
     * Format the SQL error message.
     *
     * @param  string     $sql
     * @param  array      $bindings
     * @param  \Exception $previous
     * @return string
     */
    protected function formatMessage($sql, $bindings, $previous)
    {
        return $previous->getMessage() . ' (SQL: ' . Str::replaceArray('?', $this->quoteBindings($bindings), $sql) . ')';
    }

    /**
     * Quote binding parameters.
     *
     * @param  array $bindings
     * @return array
     */
    protected function quoteBindings(array $bindings): array
    {
        return collect($bindings)->map(function ($value) {
            if ($value === null) {
                return 'null';
            }
            if (is_string($value)) {
                return $this->pdo->quote($value);
            }
            if (is_bool($value)) {
                return (int)$value;
            }
            return $value;
        })->toArray();
    }
}
```

## 実行結果

```php
\DB::statement('INSERT INTO invalid_grammar(?, ?, ?)', [1, null, 'xxx']);
```

> App/Database/QueryException with message 'SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '1, NULL, 'xxx')' at line 1 (SQL: INSERT INTO invalid_grammar(1, null, 'xxx'))'

これで Sequel Pro とかにコピペして検証もやりやすくなるかも！