57
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Last updated at Posted at 2023-07-04

先日の Taylor 氏の Tweet

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

何の話?

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

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

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

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

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

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

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

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

$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の実行クエリログを出力する に書いてあるように,文字列はシングルクォーテーションで囲む,日付は format するなどの下処理が必要になります.

今回のアップデートは?

Taylor 氏によると,全てのパラメータがバインドされた状態でクエリを取得できる toRawSql() が実装されたそうです.
これを使うことで,先程のバインド処理が不要になり,一発で ”完全な SQL クエリ” が取得できるようになります.

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

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

PR を見てみよう

今回の変更が行われた 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 に実装されました.
主に値をバインドする処理が書かれている部分ですね.

Illuminate\Database\Query\Grammars\Grammar::substituteBindingsIntoRawSql()
/**
 * 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;
}

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

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

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

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"]);
});

スッキリ!

まとめ

はやくリリースされてほしいね

57
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?