Kodokon kodokon.com

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

Настрой PDO так, как это делают в продакшене, структурно устрани SQL-инъекции и сделай записи надёжными с помощью транзакций.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

PDO - это единый интерфейс доступа к базам данных: MySQL, PostgreSQL или SQLite управляются одним и тем же API. Наивное подключение от продакшен-подключения отделяют три настройки: ERRMODE_EXCEPTION, чтобы каждая SQL-ошибка поднимала исключение, а не падала молча, FETCH_ASSOC по умолчанию для чистых результатов и EMULATE_PREPARES в false, чтобы подготовленные запросы действительно выполнялись сервером. Добавь charset=utf8mb4 прямо в DSN - это единственное надёжное место, где его стоит объявлять.

PHP
<?php

declare(strict_types=1);

$dsn = 'mysql:host=localhost;dbname=shop'
    . ';charset=utf8mb4';

$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
];

$pdo = new PDO($dsn, 'app_user', 'secret', $options);
Продакшен-подключение: исключения, ассоциативная выборка, нативная подготовка.

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

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$insert = $pdo->prepare(
    'INSERT INTO users (email) VALUES (:email)'
);
$insert->execute(['email' => 'lea@example.com']);

$query = $pdo->prepare(
    'SELECT id, email FROM users
     WHERE email = :email'
);
$query->execute(['email' => 'lea@example.com']);

var_dump($query->fetch(PDO::FETCH_ASSOC));
Самодостаточный пример на SQLite: подготовка, привязка, выполнение.

Транзакция гарантирует атомарность: либо все записи проходят, либо ни одна. Канонический паттерн: beginTransaction(), работа внутри try, commit() в конце блока, rollBack() в catch - и затем ты пробрасываешь исключение дальше, потому что спрятать сбой хуже самого сбоя. Держи транзакции короткими: любая блокировка, удерживаемая во время сетевого вызова или долгой обработки, ухудшает конкурентность во всём приложении.

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE accounts (
        id INTEGER PRIMARY KEY,
        balance INTEGER NOT NULL
    )'
);
$pdo->exec(
    'INSERT INTO accounts (balance)
     VALUES (100), (20)'
);

$pdo->beginTransaction();

try {
    $debit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance - :n
         WHERE id = :id'
    );
    $debit->execute(['n' => 50, 'id' => 1]);

    $credit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance + :n
         WHERE id = :id'
    );
    $credit->execute(['n' => 50, 'id' => 2]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

echo 'Transfer done';
Атомарный перевод: либо всё проходит, либо всё откатывается.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Почему подготовленный запрос блокирует SQL-инъекцию?
    • Он автоматически экранирует апострофы в строках
    • Структура SQL и данные передаются раздельно: данные никогда не трактуются как SQL-код
    • Он шифрует запрос между PHP и сервером
  2. Что можно передать через плейсхолдер :param в подготовленном запросе?
    • Значение: строку, число или null
    • Имя таблицы или колонки
    • Целое условие ORDER BY
  3. Что ты делаешь в блоке catch в каноническом паттерне транзакции?
    • commit(), чтобы сохранить то, что успело пройти
    • rollBack(), а затем пробросить исключение дальше
    • Ничего: транзакция истечёт сама