В недавнем прошлом мы могли выгружать SQL, который генерировал наш построитель запросов, следующим образом:
$filter = 'wew, dogs';
// Using the `toSql()` helper
DB::table('foo')
->select(['id', 'col1', 'col2'])
->join('bar', 'foo.bar_id', 'bar.id')
->where('foo.some_colum', $filter)
->toSql();
// SELECT id, col1, cole2
// FROM foo
// INNER JOIN nar on foo.bar_id = bar.id
// WHERE foo.some_column = ?
Это было полезно для отладки сложных запросов, но обратите внимание, как мы не получили значение в нашем операторе WHERE! Все, что мы получили, - это назойливый символ ? - заполнитель для значения, которое мы передаем в запрос. Фактическое значение скрыто от нас.
”Это не то, что мне нужно”, вы, возможно, сказали себе. Предполагая, что мы не отлаживаем синтаксис SQL, наши привязки запросов, вероятно, являются тем, что нас в большинстве случаев больше всего интересует.
Получение полного запроса
В новой версии Laravel 10.15 появилась возможность получить полный SQL-запрос! Это гораздо более полезно для отладки.
$filter = 'wew, dogs';
// Using the `toRawSql()` helper
DB::table('foo')
->select(['id', 'col1', 'col2'])
->join('bar', 'foo.bar_id', 'bar.id')
->where('foo.some_colum', $filter)
->toRawSql();
// SELECT "id", "col1", "col2"
// FROM "foo"
// INNER JOIN "bar" ON "foo"."bar_id" = "bar"."id"
// WHERE "foo"."some_colum" = 'wew, dogs'"
то гораздо лучше!
Секрет заключается в том, что PDO (основная библиотека, используемая для подключения к базам данных) не предоставляет нам SQL-запроса сразу - мы можем получить только SQL с заполнителями (отсюда и ограничение старого метода toSql()).
Поэтому нам нужно сами создать запрос с учетом наших значений! Как это делается?
Как это работает
Это довольно сложная задача, поскольку мы имеем дело с пользовательским вводом - любой нелепый запрос, который разработчик (или его пользователи) могут передать в SQL-запрос, должен быть правильно экранирован.
Новый вспомогательный метод toRawSql() выполняет важную логику в методе с именем substituteBindingsIntoRawSql(). Вот ссылка на соответствующий pull request для справки.
Если мы немного вглядимся в код этого метода, мы увидим, что в нем происходит!
Первое, что делает функция, - это экранирует все значения. Это позволяет Laravel выводить запрос как строку, не беспокоясь о несоответствии кавычек или подобных проблем.
$bindings = array_map(fn ($value) => $this->escape($value), $bindings);
Вызов $this->escape() передает выполнение объекту подключения к базе данных и глубже - в базовый объект PDO. PDO фактически выполняет работу по экранированию значений запроса безопасным образом.
Если ваше подключение к базе данных не настроено или не работает, вы можете получить ошибку ”Отказ в соединении” при использовании метода toRawSql(). Это происходит потому, что внутренний код использует объект “connection” (внутри PDO) для экранирования символов в выводе.
После экранирования значений метод проходит по запросу посимвольно!
for ($i = 0; $i < strlen($sql); $i++) {
$char = $sql[$i];
$nextChar = $sql[$i + 1] ?? null;
// and so on
}
Различные базы данных, которые поддерживаются в Laravel, используют разные конвенции для символов экранирования. Этот код пытается найти символы экранирования и игнорировать их, чтобы не пытаться подставить что-то, что похоже на символ привязки запроса, но им не является. Это самый сложный момент в этой новой функции.
$query = '';
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, ["\'", "''", '??'])) {
// We are building the query string back up - We ignore escaped characters
// and append them to our rebuilt query string. Since we append
// two characters, we `$i += 1` so the loop skips $nextChar in our `for` loop
$query .= $char.$nextChar;
$i += 1;
} ...
}
Цикл for перестраивает строку запроса, подставляя значения вместо их заполнителей ?. Первая проверка здесь ищет определенные символы экранирования. Для того чтобы знать, является ли символ экранирования, и, следовательно, не следует выполнять замену, необходимо знать текущий символ И следующий символ.
Следующая часть условия - это:
} elseif ($char === "'") { // Starting / leaving string literal...
$query .= $char;
$isStringLiteral = ! $isStringLiteral;
}
Если мы открываем неэкранированную кавычку, это означает, что мы находимся в начале (или конце) строкового литерала. Мы устанавливаем флаг для этого случая, который важен для нашей следующей проверки.
elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding...
$query .= array_shift($bindings) ?? '?';
}
Вот волшебство. Если мы НЕ находимся внутри строкового литерала, И мы не находим экранированный символ, И находим символ ?, тогда мы можем предположить, что это привязка запроса, которую нужно заменить фактическим значением. Мы берем наш массив значений и сдвигаем его - удаляем первый элемент в этом массиве и добавляем его значение к нашей строке запроса. (Массив значений находится в том же порядке, что и символы привязки запроса - ? - в запросе).
Наконец, если у нас есть обычный символ без особого значения, мы просто добавляем его к нашей строке запроса:
else { // Normal character...
$query .= $char;
}
Вот весь метод, таким образом, как он существует на момент написания мною этого ответа:
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;
}
Это, в общем, все, что касается этой истории. Мы хотим, чтобы весь запрос был доступен с нашими значениями! Есть некоторые (незначительные) ограничения, которые мы должны учесть, чтобы использовать эту функцию:
- Нам необходимо установить соединение с базой данных (благодаря использованию библиотеки PDO для безопасного экранирования значений).
- Вышеуказанный код МОЖЕТ содержать случайные ошибки в зависимости от используемых значений.
- Очень длинные значения в запросе, например, бинарные данные, могут привести к полному беспорядку в строке запроса.
Эти компромиссы мне кажутся приемлемыми!