RE:NODE

PDO в PHP: подготовленные запросы и обработка ошибок

Автор Команда RE:NODE
19 мин чтения


Большинство багов в PDO связаны не с подключением. Они возникают между prepare() и строкой, которую вы в итоге получаете: в массиве плейсхолдеров лишний ключ, булево значение приходит в MySQL пустой строкой, bindParam() в цикле привязывает все строки к последнему значению, список IN () собран конкатенацией строк, lastInsertId() прочитан на один запрос позже, а в блоке catch сравнивается не тот код ошибки. У каждой из этих проблем есть короткое точное правило. Этот материал - справочник по ним: как сопоставляются плейсхолдеры, как типизируются значения, как сообщается об ошибках и что означает каждый частый SQLSTATE, какой режим выборки подходит для какой формы данных, и как на самом деле ведут себя транзакции, ID вставки и счётчики строк в MySQL.

Предполагается, что подключение уже работает. DSN, charset=utf8mb4, три полезные опции, ловушка с сокетом при localhost и TLS разобраны в статье PHP PDO и MySQL. Примеры здесь используют $pdo, созданный именно так, с ERRMODE_EXCEPTION, FETCH_ASSOC в качестве режима выборки по умолчанию и ATTR_EMULATE_PREPARES, равным false.

Позиционные или именованные плейсхолдеры#

PDO принимает два стиля плейсхолдеров. Позиционные - это просто ?, значения сопоставляются по порядку. Именованные - двоеточие и имя, :email, значения сопоставляются по имени. Оба стиля дают один и тот же подготовленный запрос; выбор зависит от того, что читается лучше и что проще сохранять корректным по мере роста запроса.

php
// Positional: values in the order the ? appear$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND status = ?');$stmt->execute([$email, 'active']);// Named: keys match names, order does not matter$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email AND status = :status');$stmt->execute(['status' => 'active', 'email' => $email]);

Позиционные плейсхолдеры подходят для двух-трёх значений. Дальше выигрывают именованные: если вставить новое условие в середину позиционного запроса, все последующие значения молча сдвинутся на одно, и при совместимых типах MySQL выполнит не тот запрос без единой жалобы.

Правила, на которых чаще всего спотыкаются:

  • Нельзя смешивать два стиля в одном запросе. WHERE a = ? AND b = :b завершится ошибкой SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters.
  • Двоеточие в ключе массива необязательно. Работают и ['email' => $x], и [':email' => $x]. Выберите один вариант и придерживайтесь его: если в кодовой базе встречаются оба, поиск усложняется.
  • Количество должно совпадать точно. Ключ, которого нет в запросе, не игнорируется, а даёт HY093: Invalid parameter number: number of bound variables does not match number of tokens. На этом попадаются те, кто передаёт целый массив $_POST или повторно используемый массив параметров в запрос, которому нужна лишь его часть.
  • Позиционные массивы должны быть списками. После array_filter() или unset() ключи уже не 0, 1, 2, и execute() не сможет их сопоставить. Сначала оберните массив в array_values().
  • Именованный плейсхолдер можно использовать один раз при нативных prepared statements. WHERE title LIKE :q OR body LIKE :q работает при эмуляции и падает с HY093 при нативном режиме. Используйте :q1 и :q2 и передайте значение дважды.
  • Плейсхолдеры хранят только значения. Имена таблиц и столбцов, ASC/DESC и ключевые слова SQL привязать нельзя; их берут из белого списка в вашем коде.

Плейсхолдер также не может находиться внутри строки в кавычках. LIKE '%:q%' - это строковый литерал с символами :q, а не плейсхолдер. Соберите шаблон в PHP и привяжите его целиком: LIKE :q со значением '%' . $term . '%'. Если поисковая строка может содержать % или _, и они должны совпадать буквально, сначала экранируйте их через addcslashes($term, '%_\\'): внутри LIKE оба символа являются подстановочными, и плейсхолдер этого не меняет.

bindValue, bindParam и типы значений#

execute($array) - краткая форма. Она привязывает каждое значение как PDO::PARAM_STR, кроме null, который отправляется как SQL NULL. MySQL сам преобразует строки в числа, когда этого требует столбец или сравнение, так что для большинства запросов это именно то, что нужно. Длинная форма - привязка значений по одному, и именно так вы управляете типом.

Есть два метода привязки, и они ведут себя по-разному:

МетодЧто привязываетКогда читается значение
bindValue()Копию значенияВ момент вызова
bindParam()Ссылку на переменнуюКогда выполняется execute()

bindParam() существует для того, чтобы один подготовленный запрос можно было выполнять многократно, меняя переменную между запусками. Это поведение со ссылкой - заодно и его классический баг:

php
// Wrong: every placeholder ends up bound to the last $valueforeach ($params as $key => $value) {    $stmt->bindParam($key, $value);}// Right: bindValue copies the value nowforeach ($params as $key => $value) {    $stmt->bindValue($key, $value);}

В первом цикле каждый вызов bindParam() привязывает одну и ту же переменную $value, и к моменту execute() в ней лежит то, что оставила последняя итерация. Используйте bindValue(), если только вам не нужна именно ссылка.

Константы типов, важные для MySQL:

КонстантаДля чего
PDO::PARAM_STRСтроки, десятичные числа, даты - по умолчанию
PDO::PARAM_INTЦелые числа, особенно LIMIT и OFFSET
PDO::PARAM_NULLЯвный NULL
PDO::PARAM_BOOLБулевы значения - но см. ниже
PDO::PARAM_LOBБольшие бинарные данные, включая потоковый ресурс

При индивидуальной привязке позиционные плейсхолдеры нумеруются с 1: $stmt->bindValue(1, $id, PDO::PARAM_INT). Именованные принимают имя, с двоеточием или без.

Булевы значения, NULL и деньги

Три вида значений вызывают большинство сюрпризов с типами.

PHP false, переданный через execute(), преобразуется в строку, а false как строка - это ''. Значение sql_mode по умолчанию в MySQL 8.0 и 8.4 включает STRICT_TRANS_TABLES, поэтому вставка '' в столбец TINYINT завершается ошибкой 1366, Incorrect integer value: '' for column 'is_active'. Без строгого режима та же вставка проходит с предупреждением и сохраняет 0, что скрывает баг до тех пор, пока кто-нибудь не включит строгий режим. Приводите булевы значения к целым перед привязкой - (int) $isActive - и проблема исчезает в обоих режимах.

NULL привязывается корректно, но сравнивается не так, как ожидают. WHERE deleted_at = ? со значением null не находит ни одной строки, потому что в SQL NULL = NULL не истинно. Чтобы найти значение, которое может быть NULL, используйте null-safe оператор MySQL: WHERE deleted_at <=> ?. Он считает два NULL равными, а в остальном ведёт себя как =.

Деньги и другие точные десятичные числа следует привязывать как строки, а не как float. '19.99' попадает в столбец DECIMAL(10,2) точно; 19.99 как PHP float - это двоичное приближение, которое MySQL затем округляет. То же относится и к обратному пути: столбцы DECIMAL возвращаются строками даже при нативных prepared statements, и так задумано, чтобы значение не портилось преобразованием во float.

Списки IN() без ошибок#

Привязать массив к одному плейсхолдеру нельзя. WHERE id IN (?) с [1, 2, 3] не работает, а склеивание значений в строку SQL сводит на нет смысл подготовленных запросов. Решение - по одному плейсхолдеру на значение, сгенерированному из массива.

С позиционными плейсхолдерами:

php
if ($ids === []) {    return [];  // IN () is a syntax error; an empty list matches nothing anyway}$marks = implode(',', array_fill(0, count($ids), '?'));$stmt = $pdo->prepare("SELECT id, name FROM products WHERE id IN ($marks)");$stmt->execute(array_values($ids));

Когда в запросе есть другие условия, позиционные плейсхолдеры заставляют объединять массивы строго в том порядке, в котором стоят знаки ?, а это хрупко. Именованные сохраняют читаемость:

php
$params = ['status' => 'active'];$names = [];foreach (array_values($ids) as $i => $id) {    $names[] = ":id$i";    $params["id$i"] = (int) $id;}$sql = 'SELECT id, name FROM products WHERE status = :status AND id IN ('     . implode(', ', $names) . ')';$stmt = $pdo->prepare($sql);$stmt->execute($params);

Сгенерированные имена берутся из счётчика цикла, а не из входных данных, поэтому в текст SQL не попадает ничего, что пришло от пользователя.

Очень длинные списки

Действуют два ограничения. Протокол MySQL допускает не более 65 535 плейсхолдеров в одном подготовленном запросе; сверх этого prepare() падает с ошибкой 1390, Prepared statement contains too many placeholders. Задолго до этого запрос с десятками тысяч значений становится медленным при разборе и планировании, а весь пакет должен уместиться в max_allowed_packet (в MySQL 8 по умолчанию 64 МБ). На практике разбивайте список на части по 500-1000 значений и выполняйте запрос по одному разу на каждую часть:

php
$rows = [];foreach (array_chunk($ids, 1000) as $chunk) {    $marks = implode(',', array_fill(0, count($chunk), '?'));    $stmt = $pdo->prepare("SELECT id, name FROM products WHERE id IN ($marks)");    $stmt->execute($chunk);    array_push($rows, ...$stmt->fetchAll());}

Альтернатива в MySQL 8 - отправить список одной JSON-строкой и распаковать её на сервере через JSON_TABLE; для этого нужен один плейсхолдер при любой длине списка:

sql
SELECT p.id, p.nameFROM products pJOIN JSON_TABLE(?, '$[*]' COLUMNS (id INT PATH '$')) AS j ON j.id = p.id;

Привяжите к нему json_encode(array_values($ids)). Текст запроса не меняется, что полезно, когда один и тот же запрос выполняется со списками разной длины. Не используйте FIND_IN_SET(id, ?) со строкой через запятую: он работает, но не может использовать индекс и сканирует всю таблицу.

Что меняют нативные prepared statements в части ошибок#

ATTR_EMULATE_PREPARES => false - рекомендуемая настройка, и статья о подключении объясняет почему: настоящие серверные запросы, нативные типы integer и float и ошибки, которые приходят раньше. Она же меняет то, где и как появляются ошибки, а это важно для их обработки.

  • Синтаксические ошибки приходят на `prepare()`. При нативном режиме prepare() отправляет SQL в MySQL, тот разбирает его и сразу отвергает опечатку с SQLSTATE 42000, ошибка 1064. При эмуляции prepare() лишь локально разбирает плейсхолдеры, и та же опечатка проявляется на execute(). Если ваш блок try оборачивает только execute(), в нативном режиме исключение окажется за его пределами.
  • Неизвестные таблицы и столбцы тоже приходят на `prepare()` - 42S02 и 42S22 - по той же причине.
  • Один запрос на один prepare. Нативный подготовленный запрос содержит ровно один оператор SQL. prepare('UPDATE a SET x = 1; UPDATE b SET y = 2') - синтаксическая ошибка. При эмуляции драйвер MySQL по умолчанию принимает несколько операторов, что удобно для скриптов миграции и становится подарком для любого, кто найдёт точку инъекции. Параметр PDO::MYSQL_ATTR_MULTI_STATEMENTS => false в опциях подключения закрывает эту возможность, даже когда эмуляция включена (в PHP 8.4 и новее константа называется Pdo\Mysql::ATTR_MULTI_STATEMENTS).
  • Запросы - это ресурсы сервера. Каждый нативный prepare создаёт на сервере запрос, который живёт, пока не будет уничтожен объект PDOStatement. MySQL ограничивает общее число по всем соединениям параметром max_prepared_stmt_count, по умолчанию 16 382. Долгоживущий воркер, который вызывает prepare() внутри цикла и хранит все объекты запросов в массиве, может упереться в ошибку 1461, Can't create more than max_prepared_stmt_count statements. Готовьте запрос один раз вне цикла и выполняйте многократно, либо давайте объектам выходить из области видимости.

Режимы ошибок и что несёт PDOException#

В PDO три режима ошибок, они задаются через PDO::ATTR_ERRMODE:

РежимПри ошибке
ERRMODE_SILENTВозвращает false; вы проверяете errorInfo()
ERRMODE_WARNINGВызывает E_WARNING и возвращает false
ERRMODE_EXCEPTIONБросает PDOException - режим по умолчанию с PHP 8.0

Исключение из правила - конструктор: new PDO(...) бросает PDOException при неудачном подключении в любом режиме ошибок, потому что нет объекта, у которого можно вызвать errorInfo(). Всё после создания объекта подчиняется выбранному режиму.

Тихий режим - источник самых дорогих багов PDO: неудачный execute() возвращает false, код идёт дальше, и в итоге вместо ошибки вы получаете пропавшую строку заказа. В тихом режиме ошибки к тому же хранятся в двух местах: неудачный prepare() записывается в объекте PDO ($pdo->errorInfo()), а неудачный execute() - в объекте запроса ($stmt->errorInfo()). Код, который проверяет не то место, видит 00000 - успех. Если вам достался код в тихом режиме, перевод его на исключения - обычно лучшее одиночное исправление из доступных.

PDOException несёт ошибку в трёх формах:

php
try {    $stmt->execute($params);} catch (PDOException $e) {    $sqlstate = $e->getCode();          // '23000' - a string, not an int    $driverCode = $e->errorInfo[1] ?? null;  // 1062 - the MySQL error number    $driverMsg = $e->errorInfo[2] ?? null;   // "Duplicate entry 'a@b.c' for key ..."}

getCode() возвращает пятисимвольный SQLSTATE в виде строки, поэтому PDOException - одно из немногих исключений, у которых getCode() не целое число; строгое сравнение с числом 23000 никогда не сработает. errorInfo - массив из SQLSTATE, номера ошибки драйвера и сообщения драйвера. Для неудачных подключений используйте сообщение и getCode(), в котором лежит номер MySQL, например 1045 или 2002; не рассчитывайте, что там заполнен errorInfo.

Ветвитесь по номеру MySQL, где это возможно. SQLSTATE - это категория, а некоторые категории переполнены: 23000 охватывает дубликаты, нарушения внешних ключей и NULL в столбце NOT NULL, а HY000 - десятки несвязанных ошибок.

Ловите узко, логируйте полностью

Перехватывайте PDOException только там, где можете сделать с ним что-то конкретное - превратить дубликат email в ошибку формы, повторить запрос после дедлока, - а всё остальное пробрасывайте дальше. Пусть неожиданные ошибки базы данных доходят до одного обработчика на верхнем уровне приложения, который пишет полное сообщение в лог и показывает посетителю общую страницу. Сообщение может содержать имена хостов, имена пользователей, фрагменты запроса и данных, поэтому место ему в логе, но не в ответе. Остальное, что не должно утекать, разобрано в чек-листе безопасности баз данных.

Справочник по SQLSTATE и ошибкам MySQL#

Ошибки, которые вы реально встретите от запросов, в отличие от подключений (о них - в статье о подключении). Сначала ошибки ограничений и данных:

SQLSTATE / MySQLСообщение начинается сОбычная причина
23000 / 1062Duplicate entryКоллизия уникального ключа
23000 / 1452Cannot add or update a child rowНет целевой строки внешнего ключа
23000 / 1451Cannot delete or update a parent rowНа строку всё ещё есть ссылки
23000 / 1048Column 'x' cannot be nullNULL в NOT NULL
22001 / 1406Data too long for columnСтрока длиннее столбца
22003 / 1264Out of range valueЧисло слишком велико для типа
22007 / 1292Incorrect datetime valueНеверный формат даты или ''
HY000 / 1366Incorrect integer value'' из PHP false или некорректный текст

Затем ошибки операторов и выполнения:

SQLSTATE / MySQLСообщение начинается сОбычная причина
42000 / 1064You have an error in your SQL syntaxОпечатка или зарезервированное слово в роли имени
42S02 / 1146Table ... doesn't existНе та база данных или пропущенная миграция
42S22 / 1054Unknown columnОпечатка или столбец, добавленный после деплоя
21S01 / 1136Column count doesn't matchСтолбцы и значения в INSERT не совпадают
HY093Invalid parameter numberПлейсхолдеры и значения не совпадают
40001 / 1213Deadlock foundВся транзакция откатана - повторите
HY000 / 1205Lock wait timeout exceededОжидание блокировки строки 50 с
HY000 / 2014Cannot execute queries while other unbuffered queries are activeОткрытый курсор

О трёх из них стоит сказать отдельно. У HY093 нет номера MySQL, потому что её вызывает сам PDO до того, как что-либо дойдёт до сервера; это всегда несоответствие между плейсхолдерами и переданными значениями, а случаи перечислены в разделе о плейсхолдерах выше. 22001 и 22003 появляются только в строгом режиме: без STRICT_TRANS_TABLES MySQL обрезает строку или ограничивает число, сохраняет результат и выдаёт предупреждение, которое никто не читает. А 1064 на внешне корректном запросе часто означает столбец с именем order, key, rank или groups - всё это зарезервированные слова, которые нужно брать в обратные кавычки.

50 секунд для 1205 - это значение innodb_lock_wait_timeout по умолчанию. Дедлоки и ожидания блокировок с оберткой для повторов разобраны в статье о транзакциях, блокировках и дедлоках в MySQL.

Режимы выборки помимо FETCH_ASSOC#

FETCH_ASSOC по умолчанию подходит большинству кода. Остальные режимы избавляют от цикла каждый раз, когда нужна форма данных, отличная от "списка строк". Передавайте их в fetch() или fetchAll() либо задавайте для запроса через $stmt->setFetchMode().

РежимКакая форма возвращается
FETCH_NUMСтроки с индексами 0, 1, 2
FETCH_OBJСтроки как объекты stdClass
FETCH_COLUMNПлоский список первого столбца
FETCH_KEY_PAIR[первый => второй] из двух столбцов
FETCH_UNIQUEСтроки с ключом из первого столбца
FETCH_GROUPСписки строк, сгруппированные по первому столбцу
FETCH_CLASSСтроки, превращённые в объекты класса

FETCH_UNIQUE люди упускают чаще всего. SELECT id, name, price FROM products с fetchAll(PDO::FETCH_UNIQUE) возвращает [17 => ['name' => ..., 'price' => ...], ...] с ключом по ID - ровно ту таблицу поиска, которую иначе пришлось бы строить циклом foreach. Если первый столбец не уникален, более поздние строки без предупреждения перезаписывают более ранние.

FETCH_GROUP можно сочетать с FETCH_COLUMN: SELECT category, name FROM products с fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN) вернёт ['tools' => ['saw', 'drill'], 'paint' => [...]].

Превращение строк в объекты класса

FETCH_CLASS создаёт объект на каждую строку и присваивает столбцы свойствам с теми же именами:

php
final class Product{    public int $id;    public string $name;    public string $price;    public function __construct() { /* runs after properties are set */ }}$stmt = $pdo->query('SELECT id, name, price FROM products');$products = $stmt->fetchAll(PDO::FETCH_CLASS, Product::class);

Два поведения удивляют людей. По умолчанию PDO задаёт свойства до вызова конструктора, поэтому конструктор, присваивающий значения по умолчанию, перезаписывает полученные данные. Добавьте PDO::FETCH_PROPS_LATE (FETCH_CLASS | FETCH_PROPS_LATE), чтобы конструктор выполнялся первым. А столбец без подходящего свойства всё равно присваивается как динамическое свойство, что с PHP 8.2 вызывает уведомление об устаревании, если класс не разрешает динамические свойства. Выбирайте только те столбцы, которые объявлены в классе, или задайте им псевдонимы под нужные имена.

Типизированные свойства взаимодействуют с типами выборки: при нативных prepared statements столбец INT приходит как int и подходит для свойства int; DECIMAL приходит строкой, поэтому $price выше объявлено как string.

Пустые результаты

fetch() и fetchColumn() возвращают false, когда строк больше нет; fetchAll() возвращает пустой массив. Поэтому if ($row = $stmt->fetch()) - правильная проверка существования, а fetchColumn() для запроса с COUNT(*) безопасен: счётчик всегда возвращает одну строку. Остерегайтесь fetchColumn() для столбца, который может законно содержать значения, похожие на false: 0 или '0' возвращаются как есть, но код, проверяющий результат через if (!$value), воспримет их как "строки нет".

Транзакции, savepoint и повторы#

Базовый шаблон - beginTransaction(), работа, commit() и rollBack() в catch - описан в статье о подключении. Ошибки в коде возникают на краях.

PDO не поддерживает вложенные транзакции. Вызов beginTransaction() при активной транзакции бросает There is already an active transaction. Коду, который из одной транзакции вызывает вспомогательную функцию, открывающую свою, нужна либо обёртка, отслеживающая глубину, либо savepoint, которые MySQL поддерживает обычным SQL:

php
function transactional(PDO $pdo, callable $work): mixed{    static $depth = 0;    $outer = $depth === 0;    $outer ? $pdo->beginTransaction() : $pdo->exec("SAVEPOINT sp$depth");    $depth++;    try {        $result = $work($pdo);        $depth--;        $outer ? $pdo->commit() : $pdo->exec("RELEASE SAVEPOINT sp$depth");        return $result;    } catch (Throwable $e) {        $depth--;        if ($outer) {            if ($pdo->inTransaction()) {                $pdo->rollBack();            }        } else {            $pdo->exec("ROLLBACK TO SAVEPOINT sp$depth");        }        throw $e;    }}

Сбой во внутреннем блоке откатывает только до его savepoint; продолжать ли работу, решает внешняя транзакция. Так же работает DB::transaction() в Laravel, поэтому вложенность там не бросает исключение.

Вещи, которые завершают транзакцию без спроса:

  • DDL. CREATE, ALTER, DROP и TRUNCATE в MySQL фиксируются неявно. Изменения до них закоммичены и не откатываются, а последующий commit() в PHP 8 бросает There is no active transaction.
  • Дедлок. Ошибка 1213 откатывает на сервере всю транзакцию. Повторять последний оператор неправильно; повторяйте всю единицу работы с beginTransaction().
  • Потеря соединения. Ошибка 2006 или 2013 посреди транзакции означает, что сервер её откатил. Объект PDO больше непригоден.

Таймауты ожидания блокировки (1205) - особый случай: по умолчанию они откатывают только оператор, у которого истёк таймаут, оставляя транзакцию открытой. Откатывайте явно перед повтором, иначе вы зафиксируете половину работы.

Держите транзакции короткими и не пускайте в них ничего медленного. Транзакция, которая удерживает блокировки строк, пока вызывает платёжный API или отправляет письмо, заставляет ждать все остальные запросы к этим строкам - а за ожиданием следуют таймауты.

Особые случаи lastInsertId и rowCount#

$pdo->lastInsertId() возвращает значение AUTO_INCREMENT, сгенерированное последним оператором на этом соединении, в виде строки. Оно привязано к соединению, поэтому параллельные запросы не видят чужих ID. Особые случаи:

  • Читайте сразу. Следующий оператор, не генерирующий ID, может сбросить значение в 0. Вызывайте метод сразу после INSERT, до любого другого запроса на этом соединении.
  • Многострочная вставка возвращает первый ID. INSERT INTO tags (name) VALUES (?), (?), (?) возвращает ID первой строки. Не вычисляйте остальные, прибавляя 1 и 2: в зависимости от innodb_autoinc_lock_mode и параллельных вставок это допущение не гарантировано. Если нужны все ID, вставляйте по одной строке внутри транзакции или читайте их обратно по уникальному ключу.
  • Таблицы без `AUTO_INCREMENT` - с ключами UUID, естественными ключами - возвращают '0'. Генерируйте ключ в PHP, и он у вас уже есть.
  • `INSERT IGNORE`, пропустивший строку, не генерирует ID. Проверьте rowCount(), чтобы узнать, была ли вообще вставка.

INSERT ... ON DUPLICATE KEY UPDATE требует внимания в обоих отношениях. MySQL сообщает rowCount(), равный 1 для новой строки, 2 для обновлённой существующей и 0 для существующей строки, в которой уже были такие значения. А если строка уже существовала, lastInsertId() не даёт её ID - если не использовать задокументированный приём MySQL с передачей ID через LAST_INSERT_ID(expr):

sql
INSERT INTO users (email, name) VALUES (:email, :name)ON DUPLICATE KEY UPDATE name = VALUES(name), id = LAST_INSERT_ID(id);

Теперь lastInsertId() возвращает ID строки в обоих случаях. В MySQL 8.0.20 VALUES(col) в этой конструкции объявили устаревшим в пользу псевдонима строки (VALUES (...) AS new ... UPDATE name = new.name); VALUES() по-прежнему работает в 8.4, но выдаёт предупреждение, поэтому в новом коде используйте форму с псевдонимом.

Для UPDATE rowCount() считает изменённые строки, а не найденные, поэтому обновление строки до её текущих значений возвращает 0. Параметр PDO::MYSQL_ATTR_FOUND_ROWS => true при подключении переключает счёт на найденные строки. Для SELECT rowCount() вообще не используйте: руководство PHP предупреждает, что его результат для SELECT непереносим. Считайте через COUNT(*) или считайте полученный массив.

Где это работает на RE:NODE#

Ничто из сказанного выше не зависит от хостинга, но несколько фактов пригодятся, если вы запускаете это у нас. MySQL на хостинге баз данных - это MySQL 8.4 LTS, поэтому JSON_TABLE, форма ON DUPLICATE KEY UPDATE с псевдонимом строки и умолчания 8.x, описанные в этой статье, применимы; пароль root, база данных приложения и пользователь приложения создаются вместе с сервером. Линейка Laravel / PHP запускает PHP 8.3, 8.4 или 8.5 на FrankenPHP, что важно для имён констант Pdo\Mysql, а тарифы для приложений и сайтов включают слоты баз данных, создаваемые в панели со сгенерированными хостом, пользователем и паролем. "Open in phpMyAdmin" на слоте - самый быстрый способ выполнить SHOW CREATE TABLE, когда нужно увидеть, какой столбец всё ещё не в utf8mb4 или на какой ключ ссылается 1062.

FAQ#

Делают ли подготовленные запросы SQL-инъекцию полностью невозможной?

Они делают безопасными значения. Всё, что склеено в текст SQL - имя столбца для сортировки, имя таблицы, ключевое слово направления, LIMIT, собранный из строки, - остаётся вне защиты. Сопоставляйте такие вещи с фиксированным белым списком в коде. Вторая лазейка - сохранённые данные, которые используются позже: значение, безопасно вставленное через плейсхолдер, остаётся недоверенным, когда вы читаете его обратно, и снова должно проходить через плейсхолдер.

Стоит ли готовить запрос, который выполняется всего один раз?

Да, ради безопасности. Цена нативного prepare - один дополнительный обмен с сервером, который при близкой базе данных занимает значительно меньше миллисекунды. Код, который переключается между query() и prepare() в зависимости от наличия значения, рано или поздно ошибается; если готовить всё, что принимает входные данные, решать ничего не придётся.

Почему execute() возвращает true, но ничего не меняется?

Либо WHERE не нашёл ни одной строки - rowCount() будет равен 0, - либо работа шла внутри транзакции, которую так и не закоммитили. При выключенном autocommit или после beginTransaction() без commit() изменения откатываются, когда соединение закрывается в конце запроса. Пока отлаживаете, проверьте $pdo->inTransaction() в конце скрипта.

Можно ли использовать один PDOStatement с разными значениями?

Да, именно для этого он и предназначен. Подготовьте один раз, затем вызывайте execute() с новым массивом сколько угодно раз; при нативных prepared statements сервер разбирает SQL только один раз. Если предыдущий SELECT на этом запросе оставил непрочитанные строки, вызовите closeCursor() перед повторным выполнением.

Как залогировать SQL с подставленными значениями?

При нативных prepared statements PDO не формирует итоговую строку запроса, потому что сервер получает SQL и значения по отдельности. $stmt->debugDumpParams() выводит SQL и каждый привязанный параметр, чего обычно достаточно. Для полной записи того, что выполнил сервер, используйте общий журнал запросов MySQL на машине разработки или журнал медленных запросов в production для тех запросов, которые важны.

Это было полезно?