【朗報】Laravel の SQL 実行ログが取得しやすくなる

# 先日の Taylor 氏の Tweet
https://twitter.com/taylorotwell/status/1674423823163338764?s=20

数年越しに， `toRawSql()` メソッドが実装されたそうです！ 🎉

# 何の話？

https://qiita.com/ucan-lab/items/753cb9d3e4ceeb245341

コチラの記事にお世話になった人は大勢いるでしょう．
Laravel では，実行（される|された）クエリを取得する方法は 3 つあります．

* Query Builder の `toSql()` メソッドを使う
* DB Facade（Database Connection） の `DB::getQueryLog()` メソッドを使う
* `QueryExecuted：：class` Event の `$sql` プロパティを読み取る

これらはどれも， ”完全な SQL クエリ” ではありませんでした．

例えば， `toSql()` が返すクエリは以下のようなものでした．

```php
$sql = User::query()->whereIn('id', [1, 2, 3])->toSql();

// "select * from "users" where "id" in (?, ?, ?)"
```

ご存知の通り，パラメータ部分は SQL インジェクション対策として，プレースホルダ「？」で埋められています．

これを ”完全な SQL クエリ” にするためには， `toSql()` と合わせて `getBindings()` を使えば良いですね．

```php
$query = User::query()->whereIn('id', [1, 2, 3]);

// [1, 2, 3] が返される
$bindings = $query->getBindings(); 

// "select * from "users" where "id" in (?, ?, ?)" が返される
$sql = $query->toSql(); 

// これらを使って値を埋め込めば良い
// でも実はそんなに単純じゃない．
```

実は，プレースホルダに `$bindigns` を埋めるのは，単なる置換では上手くいかなかったりします．
これは先程印象した [Laravel SQLの実行クエリログを出力する](https://qiita.com/ucan-lab/items/753cb9d3e4ceeb245341) に書いてあるように，文字列はシングルクォーテーションで囲む，日付は format するなどの下処理が必要になります．

# 今回のアップデートは？
Taylor 氏によると，全てのパラメータがバインドされた状態でクエリを取得できる `toRawSql()` が実装されたそうです．
これを使うことで，先程のバインド処理が不要になり，一発で ”完全な SQL クエリ” が取得できるようになります．

```php
$sql = User::query()->whereIn('id', [1, 2, 3])->toRawSql();

// "select * from "users" where "id" in (1, 2, 3)"
```

# PR を見てみよう
https://github.com/laravel/framework/pull/47507

今回の変更が行われた Pull Request です．
PR の内容を見る限り，やはりパラメータのバインド処理に苦戦したようです．
Laravel はデフォルトで
* MySQL
* PostgreSQL
* SQLite
* SqlServer

などのドライバをサポートしていますので，ドライバによって若干仕様が異なりどの場合でも正しくバインドできるように実装するのに大分苦労したように見受けられます．
すくなくとも，この PR の Author の前に 5 人のエンジニアが挑戦し，Reject されているようです．

# Event では使えないの？
`toRawSql()` は，Query Builder に実装されたメソッドです．
一方，`DB::listen()` に登録するリスナの引数として受け取る `QueryExecuted::class` Event からは QueryBuilder を使うことができません．
したがって， ServiceProvider で定常的にクエリログを記録する場合には `toRawSql()` を使うことはできません．

でも実は，工夫することでバインド処理を自前で実装しなくて良くなったんです.

# 今回実装された `substituteBindingsIntoRawSql()`
`toRawSql()` を実装するに当たって， `substituteBindingsIntoRawSql()` というメソッドが `Illuminate\Database\Query\Grammars\Grammar::class` に実装されました．
主に値をバインドする処理が書かれている部分ですね．


https://github.com/laravel/framework/blob/10.x/src/Illuminate/Database/Query/Grammars/Grammar.php#L1360-L1389

<details><summary>Illuminate\Database\Query\Grammars\Grammar::substituteBindingsIntoRawSql()</summary>



```php
/**
 * Substitute the given bindings into the given raw SQL query.
 *
 * @param  string  $sql
 * @param  array  $bindings
 * @return string
 */
public function substituteBindingsIntoRawSql($sql, $bindings)
{
    $bindings = array_map(fn ($value) => $this->escape($value), $bindings);

    $query = '';

    $isStringLiteral = false;

    for ($i = 0; $i < strlen($sql); $i++) {
        $char = $sql[$i];
        $nextChar = $sql[$i + 1] ?? null;

        // Single quotes can be escaped as '' according to the SQL standard while
        // MySQL uses \'. Postgres has operators like ?| that must get encoded
        // in PHP like ??|. We should skip over the escaped characters here.
        if (in_array($char.$nextChar, ["\'", "''", '??'])) {
            $query .= $char.$nextChar;
            $i += 1;
        } elseif ($char === "'") { // Starting / leaving string literal...
            $query .= $char;
            $isStringLiteral = ! $isStringLiteral;
        } elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding...
            $query .= array_shift($bindings) ?? '?';
        } else { // Normal character...
            $query .= $char;
        }
    }

    return $query;
}
```
</details>

`Illuminate\Database\Events\QueryExecuted::class` には， `$connection` という属性があります．これは， `Illuminate\Database\Connection::class` のインスタンスです．

`Illuminate\Database\Connection::class` には， `getQueryGrammer()` といいうメソッドがあります．これは `Illuminate\Database\Query\Grammars\Grammar::class` を返します．

よって， `DB::listen()` では，このようにしてプロパティをバインドすることが可能になります．


```php
use Illuminate\Database\Events\QueryExecuted;

DB::listen(function(QueryExecuted $event) {
    $sql = $event->connection
        ->getQueryGrammar()
        ->substituteBindingsIntoRawSql(
            sql: $event->sql,
            bindings: $event->connection->prepareBindings($event->bindings),
        );

    Log::debug('SQL', ['sql' => $sql, 'time' => "{$event->time} ms"]);
});
```

スッキリ！

# まとめ
はやくリリースされてほしいね