先日の 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"]);
});
スッキリ!
まとめ
はやくリリースされてほしいね