Глиф Учебники

Урок 9. База данных через PDO

Курс «PHP» · урок 9 из 10 · 5 минут чтения

Настоящий сайт хранит данные в базе: пользователей, товары, заказы, комментарии. В PHP для работы с базами есть PDO (PHP Data Objects) — один способ для разных баз: SQLite, MySQL, MariaDB, PostgreSQL. Запросы к базе пишутся на языке SQL; его основы — в теме SQL.

Код сайта prepare execute fetchAll PDO одни методы sqlite: SQLite — файл app.db mysql: MySQL, MariaDB PostgreSQL Код тот же — меняется только строка подключения (DSN)
Код сайта вызывает одни и те же методы PDO — prepare, execute, fetchAll, — а базу выбирает строка подключения: sqlite: для файла app.db, mysql: для MySQL и MariaDB.

Учиться удобнее всего на SQLite: вся база — один файл, ставить отдельный сервер не нужно. Нужно расширение pdo_sqlite: в Ubuntu и Debian — пакет php-sqlite3, в Windows — строка extension=pdo_sqlite в php.ini (урок 1). Проверить: команда php -m должна показать pdo_sqlite.

Подключение

PHP
<?php
$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

Таблица, запись и чтение

Программа ниже работает с базой в памяти (sqlite::memory:) — она исчезает после завершения, и пример можно запускать сколько угодно раз с одинаковым результатом:

PHP
<?php
$pdo = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)');

$insert = $pdo->prepare('INSERT INTO notes (title) VALUES (?)');
foreach (['Купить хлеб', 'Позвонить маме', 'Купить молоко'] as $title) {
    $insert->execute([$title]);
}

$search = $pdo->prepare('SELECT id, title FROM notes WHERE title LIKE ? ORDER BY id');
$search->execute(['Купить%']);
foreach ($search->fetchAll() as $row) {
    echo $row['id'] . ': ' . $row['title'] . "\n";
}

$count = $pdo->query('SELECT COUNT(*) FROM notes')->fetchColumn();
echo "Всего заметок: $count\n";
Вывод
1: Купить хлеб
3: Купить молоко
Всего заметок: 3

Что делают методы:

prepare SELECT id, title FROM notes WHERE title LIKE ? ORDER BY id execute ['Купить%'] значение отдельно от текста запроса Таблица notes id title 1 Купить хлеб 2 Позвонить маме 3 Купить молоко fetchAll Массив строк 'id' => 1, 'title' => 'Купить хлеб' 'id' => 3, 'title' => 'Купить молоко'
prepare готовит запрос с местом ?, execute подставляет в него значение 'Купить%', из таблицы notes подходят строки 1 и 3, и fetchAll возвращает их массивом.

Номер только что добавленной записи возвращает $pdo->lastInsertId().

Именованные места

Вместо ? можно писать имена — так понятнее, когда значений много:

PHP
<?php
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = :email AND active = :active');
$stmt->execute(['email' => $email, 'active' => 1]);
$user = $stmt->fetch();
if ($user === false) {
    echo 'Пользователь не найден';
}

SQL-инъекция: почему только prepare

Так писать нельзя:

PHP
<?php
$email = $_POST['email'];
$rows = $pdo->query("SELECT * FROM users WHERE email = '$email'")->fetchAll();

Если посетитель введёт в поле почты ' OR '1'='1, запрос превратится в SELECT * FROM users WHERE email = '' OR '1'='1' — условие верно для всех строк, и сайт выдаст всех пользователей. Другим вводом можно удалить таблицу или прочитать пароли. Это SQL-инъекция — одна из самых частых и опасных уязвимостей сайтов.

Никогда не вставляйте данные от посетителя в текст SQL-запроса — ни через кавычки, ни через склейку строк. Только места ? или :имя и execute. Данные из формы, адреса, cookie и даже из вашей же базы — всё через подготовленный запрос.

В подготовленном запросе данные не склеиваются с текстом SQL в вашем коде: их подставляет сама база или PDO по строгим правилам. Что бы ни ввёл посетитель, это останется значением поля и не станет частью команды.

в поле почты введено: ' OR '1'='1 Склейка строки — нельзя "SELECT * FROM users WHERE email = '$email'" SELECT * FROM users WHERE email = '' OR '1'='1' ввод стал частью SQL: условие верно для всех prepare и execute — правильно prepare('SELECT * FROM users WHERE email = ?') execute([$email]) ' OR '1'='1 только значение поля: такой почты нет — никто не найден
При склейке ввод ' OR '1'='1 становится частью SQL, и условие верно для всех строк; в подготовленном запросе тот же ввод остаётся значением поля, и такой почты просто нет.

Места для данных годятся только для значений. Имя таблицы или столбца так подставить нельзя — например, для сортировки по выбору посетителя выбирайте столбец из списка разрешённых:

PHP
<?php
$allowed = ['title', 'created_at'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'title';
$rows = $pdo->query("SELECT id, title FROM notes ORDER BY $sort")->fetchAll();

MySQL и MariaDB

Код тот же, меняется только строка подключения. В Ubuntu и Debian нужен пакет php-mysql, в Windows — строка extension=pdo_mysql в php.ini:

PHP
<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'shop_user', 'пароль_пользователя', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

charset=utf8mb4 — кодировка соединения: с ней русский текст и эмодзи сохраняются без искажений. Базу и пользователя создают заранее, средствами самой MySQL. Логин и пароль базы держите в отдельном файле настроек вне папки сайта (урок 7).

Ошибки и вывод данных

PHP
<?php
try {
    $pdo = new PDO('sqlite:' . __DIR__ . '/app.db');
} catch (PDOException $e) {
    error_log($e->getMessage());
    exit('Сайт временно недоступен');
}

try … catch перехватывает исключение: если в блоке try случилась ошибка, выполняется блок catch, а $e->getMessage() даёт её текст. Текст записывается в журнал ошибок сервера (error_log), а посетителю показывается общая фраза: в сообщении PDO бывают имя базы, логин и путь к файлам.

Данные из базы, выводимые на страницу, тоже экранируются: <?= htmlspecialchars($row['title']) ?>. В базу могли попасть строки, которые когда-то ввёл посетитель.

Файл базы SQLite не кладите в папку, которую отдаёт веб-сервер: его можно будет скачать по адресу целиком, со всеми пользователями и паролями. Держите его уровнем выше, например __DIR__ . '/../data/app.db'.

Транзакции

Если несколько запросов должны выполниться только вместе (списать деньги с одного счёта и зачислить на другой), их объединяют в транзакцию:

PHP
<?php
$pdo->beginTransaction();
try {
    $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')->execute([100, 1]);
    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')->execute([100, 2]);
    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    throw $e;
}

commit сохраняет все изменения, rollBack отменяет их, если что-то пошло не так.

Частые ошибки

Коротко

Проверьте себя

1. Как правильно найти пользователя по почте из формы?
2. Что вернёт fetch(), если подходящих строк нет?
3. Можно ли подставить имя столбца для ORDER BY на место знака ? в подготовленном запросе?
4. Почему посетителю не показывают текст PDOException?