PHP: полный мануал с примерами, функциями и ООП
Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Дерево руководства
Глава 102 · После первого SQL и параметризованного запроса
MySQL: подготовленные фильтры, буквальный LIKE, сортировка и курсор
От одного параметра остатка до списка ID, NULL, буквальных спецсимволов, допустимых сортировок и продолжения живого каталога.
От выбора пользователя к конкретным строкам каталога
В прошлых главах мы подключились к серверу, создали каталог и получили строки. Теперь данные запроса приходят из решений пользователя: выбранные товары, текст поиска, порядок сортировки и кнопка «Ещё». Научимся превращать каждое решение в понятный SQL-договор. Результат — точные наборы товаров и объяснимый отказ на неподходящий ввод, а не строка «запрос выполнен».
Перед началом нужны соединение и первая таблица, отделение SQL от значений и WHERE, ORDER BY и LIMIT. Здесь повторим смысл параметра, но не будем заново строить сервер. Шесть самостоятельных программ не зависят от запуска друг друга.
| id | Товар | Цена, коп. | Остаток |
|---|---|---|---|
| 1 | Чай | 12000 | 12 |
| 2 | Кофе | 30000 | 0 |
| 3 | Какао | 12000 | 7 |
| 4 | Мёд | 25000 | 4 |
Это общий каталог для четырёх сценариев. Цена хранится целым числом копеек:12000 означает 120 рублей, а не 12000 рублей. У чая и какао цена одинакова — поэтому сортировка и курсор используют также ID. Для NULL и LIKE специально создаются другие, явно показанные исходные строки.
- Пользователь выбирает порог 5
- PHP проверяет смысл/тип значения
- Фиксированный SQL содержит stock >= ?
- Параметр 5 передаётся отдельно
- MySQL возвращает id 1 иid 3
- PHP строит таблицу Чай / Какао
| Элемент | Зачем нужен | Где находится |
|---|---|---|
| prepare($sql) | Проверить/подготовить один шаблон запроса | Метод объекта соединения $db |
| ? | Одно место для значения, не для имени колонки | Внутри строки SQL, без кавычек вокруг? |
| bind_param('i',$minimum) | Связать целочисленный параметр с переменной | Вызов statement после prepare |
| execute() | Передать текущие значения и выполнить команду | После подготовки и связывания |
| get_result()/fetch_assoc() | Получить набор и очередную строку с именами полей | После SELECT; результат не выводится сам |
| free()/close() | Освободить прочитанный набор/statement | После использования соответствующего объекта |
Полные сигнатуры и ограничения остаются в справочнике MySQLi. bind_param связывает переменные по ссылке; буквы i/d/s/b означают целое/вещественное/строку/двоичные данные с отдельными возможностями отправки. В этих сценариях нужны i и s. Для удобного разового запроса есть mysqli_execute_query с PHP 8.2, а execute($params) доступен с PHP 8.1. Мы явно используем bind_param там, где важно увидеть типы, ссылки и повторное выполнение.
Подготовка и границы самостоятельных примеров
Проверочный стек новых программ: PHP 8.5.11 с mysqli/mysqlnd и локальный MySQL 8.4.13 LTS. Код рассчитан на PHP 8.2+; утверждение о фактическом запуске относится к указанному стенду. Настройка MySQL/пользователя, отличия сервера от Workbench, адрес 127.0.0.1 и переменные окружения подробно даны в первой главе MySQL. Каждому архиву приложена собственная README.txt с полным перечнем настроек.
- Укажите PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER и PHPMAN_MYSQL_PASSWORD в локальном терминале. Пароль не переносите в публичный код.
- Создайте отдельную учебную схему с ограниченным пользователем. Нужны SELECT/INSERT/UPDATE/DELETE/CREATE/DROP/REFERENCES/CREATE TEMPORARY TABLES только на неё; административный аккаунт не используется примером.
- Распакуйте архив в отдельную папку, выполните php example.php и откройте result.html. result.json содержит те же прочитанные значения, metrics.json — проверки/версии. Это CLI-запуск, не тест отправки браузерной формы.
- Большинство таблиц временные. Для двух соединений с общей версией и для настоящих внешних ключей создаются постоянные mysql107_edit_*; только успешно созданные этим запуском таблицы удаляются в finally. Существующие имена не очищаются перед стартом.
Шаблоны ? не принимают произвольные SQL-имена и выражения. Валидация определяет допустимый смысл, параметризация сохраняет границу SQL/данные, проверка прав ограничивает доступные строки, HTML-экранирование защищает текст результата. Это разные обязанности; ни одна не заменяет остальные.
Подготовить фильтр один раз и выполнить с тремя значениями
Менеджер меняет фильтр «минимальный остаток»: сначала показывает все товары, затем только доступные в количестве от пяти. Структура SQL не меняется; меняется число. Один подготовленный statement можно выполнить несколько раз, каждый раз прочитав новый результат.
Исходные данные. В учебном каталоге Чай имеет остаток 12, Кофе —0, Какао —7, Мёд —4. Последовательно подставим 0,5 и 20. Ноль в условии stock>=0 включает и распроданный кофе: это осознанное правило фильтра, а не пропуск проверки.
| Минимальный остаток | Товаров | Названия |
|---|---|---|
| 0 | 4 | Чай, Кофе, Какао, Мёд |
| 5 | 2 | Чай, Какао |
| 20 | 0 | (пустая строка) |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- prepare: SELECT id,name WHERE stock >= ?
- bind_param: место ? связано с $minimum
- execute при 0 → четыре товара
- Та же переменная получает 5 → execute → Чай и Какао
- Значение 20 → пустой результат; освобождаем result и statement
$statement = $db->prepare('SELECT id,name FROM mysql107_app_products WHERE stock >= ? ORDER BY id');
$minimum = 5;
$statement->bind_param('i', $minimum);
$statement->execute();Знак ? — одно место для значения; кавычки вокруг него не ставят. Буква i в bind_param означает целое число, $minimum передаётся по ссылке: execute читает его текущее значение. Присваивание нового значения той же переменной не требует нового prepare. Метод get_result даёт набор строк; fetch_assoc читает очередную строку как массив с ключами id/name. После чтения free освобождает текущий набор, close — подготовленную команду. Успешный execute не означает, что нашёлся хотя бы один товар: при 20 корректно получаем 0 строк.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect(); app107Catalog($db);
try {
$statement=$db->prepare('SELECT id,name FROM mysql107_app_products WHERE stock >= ? ORDER BY id');
$minimum=0;
$statement->bind_param('i',$minimum);
$rows=[]; $actual=[];
foreach ([0,5,20] as $minimum) {
$statement->execute();
$result=$statement->get_result();
$ids=[]; $names=[];
while ($record=$result->fetch_assoc()) { $ids[]=(int)$record['id']; $names[]=$record['name']; }
$result->free();
$actual[]=$ids;
$rows[]=[$minimum,count($ids),implode(', ',$names)];
}
$statement->close();
$checks=['three_distinct_results'=>$actual===[[1,2,3,4],[1,3],[]],'empty_is_valid'=>$rows[2][1]===0];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-reuse-prepared', ['Минимальный остаток', 'Товаров', 'Названия'], $rows, $checks, $runtime, $details);
echo "prepared reuse: 4, 2, 0 products\n";
Ожидаемый результат
prepared reuse: 4, 2, 0 products
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. Здесь повторяется один шаблон в одной сессии. Не переносите statement между соединениями. При одном выполнении подготовка не обязана ускорить запрос; её главная польза — отдельная передача структуры и данных, при повторениях ещё и повторное использование шаблона. get_result использует mysqlnd. Для большого набора нужен осознанный выбор буферизации; четыре товара не являются измерением производительности.
Попробуйте сами. Добавьте порог 4. Ожидаемые ID:1,3,4. Решение: включите 4 в список порогов, оставив SQL и bind_param прежними.
Связанные объяснения: prepare и его параметры, bind_param и ссылки, жизненный цикл результата.
Передать NULL и сохранить ведущие нули кода товара
У товара есть необязательное примечание. «Не указано», «указана пустая строка» и текст «0» — три разных случая. Кроме того, артикул 0007 должен оставаться строкой: превращение его в число 7 меняет идентификатор товара.
Исходные данные. Создадим три строки: id 1 / sku 0007 / note NULL; id 2 / sku 0008 / note пустая строка; id 3 / sku 0009 / note строка 0. Вставка использует типы iss: целый ID, строковый SKU и строковое примечание, которое может быть PHP null.
| Параметр note | id найденной строки | sku как строка |
|---|---|---|
| NULL | 1 | 0007 |
| (пустая строка) | 2 | 0008 |
| 0 | 3 | 0009 |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- PHP null передаётся параметром → SQL NULL
- 0007 передаётся как строка → остаётся 0007
- note <=> null → толькоid 1
- note <=> пустая строка → толькоid 2
- note <=> строка 0 → толькоid 3
$needle = null;
$find = $db->prepare('SELECT id,sku FROM mysql107_app_notes WHERE note <=> ?');
$find->bind_param('s', $needle);
$find->execute();NULL обозначает отсутствие значения, не текст «NULL». Привязанная PHP-переменная null передаёт SQL NULL и при строковом типе s. Обычное сравнение note = NULL не даёт true; WHERE оставляет только истинные условия, поэтому такой фильтр ничего не выберет. MySQL-оператор <=> сравнивает значения с учётом NULL: две отсутствующие величины считаются равными. Для фиксированного условия «примечания нет» проще написать IS NULL. В примере один шаблон <=> удобен потому, что значение бывает и null, и обычной строкой. Дополнительный запрос с = ? и null специально подтверждает 0 строк.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect();
try {
$db->query('CREATE TEMPORARY TABLE mysql107_app_notes (id INT PRIMARY KEY, sku VARCHAR(12) NOT NULL, note VARCHAR(40) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$insert=$db->prepare('INSERT INTO mysql107_app_notes (id,sku,note) VALUES (?,?,?)');
$id=0; $sku=''; $note=null;
$insert->bind_param('iss',$id,$sku,$note);
foreach ([[1,'0007',null],[2,'0008',''],[3,'0009','0']] as $input) {
[$id,$sku,$note]=$input;
$insert->execute();
}
$insert->close();
$find=$db->prepare('SELECT id,sku FROM mysql107_app_notes WHERE note <=> ? ORDER BY id');
$needle=null; $find->bind_param('s',$needle); $rows=[]; $ids=[];
foreach ([null,'','0'] as $needle) {
$find->execute(); $result=$find->get_result();
$record=$result->fetch_assoc(); $result->free();
$ids[]=(int)$record['id'];
$rows[]=[$needle,(int)$record['id'],$record['sku']];
}
$find->close();
$wrong=$db->prepare('SELECT id FROM mysql107_app_notes WHERE note = ?');
$needle=null; $wrong->bind_param('s',$needle); $wrong->execute();
$wrongResult=$wrong->get_result(); $equalNullCount=$wrongResult->num_rows; $wrongResult->free(); $wrong->close();
$checks=['three_meanings'=>$ids===[1,2,3],'leading_zeros_preserved'=>$rows[0][2]==='0007','equals_null_selects_nothing'=>$equalNullCount===0];
$details=['equals_null_rows'=>$equalNullCount,'null_safe_operator'=>'<=>'];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-null-parameters', ['Параметр note', 'id найденной строки', 'sku как строка'], $rows, $checks, $runtime, $details);
echo "NULL, empty and zero matched separately; SKU 0007 preserved\n";
Ожидаемый результат
NULL, empty and zero matched separately; SKU 0007 preserved
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. s/i описывают передачу параметра, а не заменяют валидацию бизнес-значения или тип колонки. Количество и ID проверяют как допустимые числа, код товара сохраняют строкой. Не применяйте empty к полю, где «0» имеет смысл, и не подменяйте любое отсутствие пустой строкой. Оператор <=> специфичен для MySQL; при переносе на другую СУБД проверьте её NULL-safe сравнение.
Попробуйте сами. Добавьте строку с note«NULL» буквами. Поиск null не должен её найти. Решение: вставьте обычную строку в VARCHAR; запрос с параметром «NULL» выберет именно её.
Связанные объяснения: NULL, пустая строка и ноль, типы bind_param, результат как массив.
Выбрать несколько ID: отдельный параметр на каждое значение
Покупатель отметил несколько товаров флажками, и нужно получить данные только по выбранным ID. Количество выбранных элементов меняется. Один параметр не разворачивается в список SQL: строка «1,3» всё ещё одно значение.
Исходные данные. Вход[3,1,3] содержит повтор; ожидаем товары 1 и 3 один раз в порядке ID. Пустой выбор должен вернуть пустую выдачу. Строка «1 OR 1=1» не является допустимым ID и отклоняется до обращения к данным.
| Запрос приложения | Найденные ID | Решение |
|---|---|---|
| [3,1,3] | 1,3 | выбрано |
| [] | (пустая строка) | пустой выбор |
| ["1 OR 1=1"] | не выполнялся | отклонено до SQL |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- Проверить каждый ID и ограничить количество 20
- Убрать повторы, восстановить последовательные ключи массива
- Для двух ID создать только синтаксис ?,?
- Привязать два целых значения; выполнить IN (?,?)
- ORDER BY id задаёт порядок; пустой список вообще не отправлять
$ids = [1, 3];
$statement = $db->prepare('SELECT id FROM mysql107_app_products WHERE id IN (?,?) ORDER BY id');
$statement->bind_param('ii', $ids[0], $ids[1]);
$statement->execute();Короткий пример показывает ровно два места. Полная функция получает массив уже разобранных положительных PHP int, проверяет его и создаёт нужное число ?. array_fill заполняет массив знаками вопроса, implode соединяет их запятыми. str_repeat создаёт столько букв i, сколько параметров; ...$ids передаёт элементы массива отдельными аргументами bind_param. Значения не склеиваются в SQL. array_values после array_unique возвращает последовательные числовые ключи. Сначала обрабатывается [], чтобы не создавать недопустимое IN (). Порядок результата задаёт ORDER BY; порядок ID во входном массиве сам по себе ничего не гарантирует.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect(); app107Catalog($db);
function selectedIds(mysqli $db,array $requested): array {
if (count($requested)>20) { throw new InvalidArgumentException('Select at most20 IDs'); }
foreach ($requested as $id) {
if (!is_int($id) || $id<1) { throw new InvalidArgumentException('Each ID must be a positive integer'); }
}
$ids=array_values(array_unique($requested));
if ($ids===[]) { return []; }
$placeholders=implode(',',array_fill(0,count($ids),'?'));
$statement=$db->prepare('SELECT id FROM mysql107_app_products WHERE id IN ('.$placeholders.') ORDER BY id');
$statement->bind_param(str_repeat('i',count($ids)),...$ids);
$statement->execute(); $result=$statement->get_result(); $found=[];
while ($record=$result->fetch_assoc()) { $found[]=(int)$record['id']; }
$result->free(); $statement->close();
return $found;
}
try {
$normal=selectedIds($db,[3,1,3]); $empty=selectedIds($db,[]); $rejected=false;
try { selectedIds($db,['1 OR 1=1']); } catch (InvalidArgumentException $error) { $rejected=true; }
$rows=[['[3,1,3]',implode(',',$normal),'выбрано'],['[]',implode(',',$empty),'пустой выбор'],['["1 OR 1=1"]','не выполнялся','отклонено до SQL']];
$checks=['only_requested'=>$normal===[1,3],'empty_means_none'=>$empty===[],'bad_input_rejected'=>$rejected];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-dynamic-in', ['Запрос приложения', 'Найденные ID', 'Решение'], $rows, $checks, $runtime, $details);
echo "IN: selected 1,3; empty selects none; invalid input rejected\n";
Ожидаемый результат
IN: selected 1,3; empty selects none; invalid input rejected
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. Ограничение 20 — договор этого примера, не лимит MySQL. Большие списки требуют другой стратегии, например временной таблицы с JOIN и пакетной загрузки. HTML-форма обычно присылает строки: сначала проверьте форму и преобразуйте разрешённый диапазон целых, не делайте слепой (int) над любой строкой. При альтернативном execute($params) PHP 8.1+ элементы передаются как строковые параметры; здесь явные i делают выбранный числовой договор видимым.
Попробуйте сами. Передайте[4,2,4]. Ожидается 2,4. Передайте[-1] — функция должна отказать до запроса. Решение уже содержится в проверке is_int/id>0 и удалении повторов.
Связанные объяснения: аргументы bind_param, execute(array) с PHP 8.1, разбор данных формы.
Ситуация встречается в вопросе How do you use IN clauses with mysqli prepared statements: Автор пытается передать изменяющийся список ID в один параметр IN и не получает ожидаемые отдельные значения. Обсуждение служит источником практической задачи; код и объяснение написаны заново, технический договор сверяется с PHP/MySQL.
Найти буквальные %, _ и ! без расширения поиска
В поиске этикеток пользователь вводит «10%» или «A_B». Он ищет именно эти символы. Параметризованный SQL защищает структуру запроса, но не отменяет правила шаблона LIKE: % означает последовательность символов, _ — один символ.
Исходные данные. Среди строк есть «Скидка 10%» и «Скидка 100», «Код A_B» и «Код AXB», а также «Знак !». Ищем буквальные 10%, A_B и!.
| Текст поиска | Шаблон LIKE | Точное совпадение | Строк |
|---|---|---|---|
| 10% | %10!%% | Скидка 10% | 1 |
| A_B | %A!_B% | Код A_B | 1 |
| ! | %!!% | Знак ! | 1 |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- Пользовательский текст 10%
- strtr экранирует именно синтаксис LIKE →10!%
- Добавляем внешние% →%10!%%
- Передаём готовый шаблон одним параметром
- LIKE ... ESCAPE ! находит только строку с настоящим%
$text = '10%';
$pattern = '%' . strtr($text, ['!'=>'!!', '%'=>'!%', '_'=>'!_']) . '%';
$find = $db->prepare("SELECT id,label FROM mysql107_app_labels WHERE label LIKE ? ESCAPE '!'");
$find->bind_param('s', $pattern);Мы выбрали ! как управляющий символ и явно указали ESCAPE '!'. strtr с массивом заменяет ! на !!, % на !%, _ на !_; замены этого вызова не обрабатываются повторно. Внешние проценты добавляются после замены: они намеренно означают «в любом месте строки». Значение затем передаётся через bind_param, а не вставляется внутрь SQL. Дополнительное выполнение шаблона%10%% без буквального экранирования находит две строки: процент в запросе расширил поиск до «Скидка 100». Таблица основного результата показывает три правильных одиночных совпадения.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect();
try {
$db->query('CREATE TEMPORARY TABLE mysql107_app_labels (id INT PRIMARY KEY, label VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$db->query("INSERT INTO mysql107_app_labels (id,label) VALUES (1,'Скидка 10%'),(2,'Скидка 100'),(3,'Код A_B'),(4,'Код AXB'),(5,'Знак !')");
$find=$db->prepare("SELECT id,label FROM mysql107_app_labels WHERE label LIKE ? ESCAPE '!' ORDER BY id");
$pattern=''; $find->bind_param('s',$pattern); $rows=[]; $ids=[];
foreach (['10%','A_B','!'] as $text) {
$pattern='%'.strtr($text,['!'=>'!!','%'=>'!%','_'=>'!_']).'%';
$find->execute(); $result=$find->get_result(); $record=$result->fetch_assoc();
$ids[]=(int)$record['id']; $rows[]=[$text,$pattern,$record['label'],$result->num_rows]; $result->free();
}
$pattern='%10%%'; $find->execute(); $result=$find->get_result(); $unescaped=$result->num_rows; $result->free(); $find->close();
$checks=['literal_matches'=>$ids===[1,3,5],'each_is_one'=>array_column($rows,3)===[1,1,1],'wildcard_would_match_two'=>$unescaped===2];
$details=['without_like_escaping_10percent_rows'=>$unescaped];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-literal-like', ['Текст поиска', 'Шаблон LIKE', 'Точное совпадение', 'Строк'], $rows, $checks, $runtime, $details);
echo "LIKE literals: percent, underscore and escape character each match one row\n";
Ожидаемый результат
LIKE literals: percent, underscore and escape character each match one row
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. Это два разных уровня: параметризация SQL и смысл шаблона LIKE. Для поиска точного равенства используйте =, там %/_ не шаблоны. Для поиска простой подстроки подходит также LOCATE/INSTR; не обещайте одинаковый план выполнения. Начальный% обычно не позволяет обычному B-tree-индексу использовать префиксный поиск. Правила регистра/акцентов определяются collation; этот пример не реализует полнотекстовый поиск. Явный! уменьшает путаницу с двойным разбором обратного слеша и NO_BACKSLASH_ESCAPES.
Попробуйте сами. Добавьте текст«100%_ready!» и попробуйте найти целиком эту подстроку. Ожидаемый шаблон:%100!%!_ready!!%. Решение: примените тот же strtr перед добавлением внешних%.
Связанные объяснения: работа со строками, параметризованное выполнение, условия запроса.
Ситуация встречается в вопросе Mysql escape % and _ for PHP PDO: Автор не уверен, превращает ли prepare символы процента и подчёркивания в буквальный текст для поиска LIKE. Обсуждение служит источником практической задачи; код и объяснение написаны заново, технический договор сверяется с PHP/MySQL.
Разрешить только известные варианты сортировки
В каталоге переключатель «дешевле сначала / дороже сначала» меняет порядок строк. Название колонки и ASC/DESC — части SQL, поэтому их нельзя передать на место значения ?.
Исходные данные. Каталог содержит две одинаковые цены 12000: id 1 иid 3. В любом разрешённом порядке ID служит дополнительным критерием. Неизвестный вариант, даже похожий на SQL, должен быть отвергнут без выполнения.
| Выбор интерфейса | Порядок ID | Решение |
|---|---|---|
| price_asc | 1,3,4,2 | разрешено |
| price_desc | 2,4,1,3 | разрешено |
| неизвестная SQL-строка | не выполнялась | отклонено |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- Интерфейс передаёт короткий ключ price_asc
- PHP ищет ключ в фиксированном словаре
- Из словаря берётся заранее написанное price_cents ASC,id ASC
- SELECT выбирает явный список колонок
- Неизвестный ключ → понятный отказ до SQL
$orders = ['price_asc'=>'price_cents ASC, id ASC', 'price_desc'=>'price_cents DESC, id ASC'];
if (!isset($orders[$choice])) { throw new InvalidArgumentException('Unknown sort'); }
$result = $db->query('SELECT id,name FROM mysql107_app_products ORDER BY ' . $orders[$choice]);В словаре слева стоят публичные названия вариантов, справа — доверенные фрагменты SQL, написанные разработчиком. В запрос попадает только значение из этого словаря. Пользовательская строка используется лишь как ключ поиска. Добавленный id ASC делает порядок однозначным при равной цене. ORDER BY ? с параметром «price_cents» означал бы значение-строку, а не выбор нужной колонки; защита имён через параметры так не работает. Перечень разрешённых вариантов также проще поддерживать, чем попытку очистить произвольную SQL-строку регулярным выражением.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect(); app107Catalog($db);
function sortedProductIds(mysqli $db,string $choice): array {
$orders=['price_asc'=>'price_cents ASC, id ASC','price_desc'=>'price_cents DESC, id ASC'];
if (!array_key_exists($choice,$orders)) { throw new InvalidArgumentException('Unknown sort option'); }
$result=$db->query('SELECT id FROM mysql107_app_products ORDER BY '.$orders[$choice]);
$ids=[]; while ($row=$result->fetch_assoc()) { $ids[]=(int)$row['id']; }
$result->free(); return $ids;
}
try {
$ascending=sortedProductIds($db,'price_asc'); $descending=sortedProductIds($db,'price_desc'); $rejected=false;
try { sortedProductIds($db,'price_cents DESC; DROP TABLE mysql107_app_products'); } catch (InvalidArgumentException $error) { $rejected=true; }
$count=(int)$db->query('SELECT COUNT(*) AS total FROM mysql107_app_products')->fetch_assoc()['total'];
$rows=[['price_asc',implode(',',$ascending),'разрешено'],['price_desc',implode(',',$descending),'разрешено'],['неизвестная SQL-строка','не выполнялась','отклонено']];
$checks=['ascending_stable'=>$ascending===[1,3,4,2],'descending_stable'=>$descending===[2,4,1,3],'bad_option_rejected'=>$rejected,'all_products_remain'=>$count===4];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-sort-allowlist', ['Выбор интерфейса', 'Порядок ID', 'Решение'], $rows, $checks, $runtime, $details);
echo "sort: ascending and descending allowed; unknown SQL rejected\n";
Ожидаемый результат
sort: ascending and descending allowed; unknown SQL rejected
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. Фиксированные SQL-фрагменты не освобождают от привязки остальных значений WHERE. Если неизвестный вариант должен заменяться умолчанием, объявите это отдельным правилом интерфейса; здесь выбран явный отказ. Добавление новых полей сортировки может требовать соответствующих индексов. Словарь не проверяет право человека читать сами строки — это отдельное условие доступа в конце второй главы.
Попробуйте сами. Добавьте stock_desc → stock DESC,id ASC. Ожидаемый порядок 1,3,4,2. Решение: расширьте только доверенный словарь и подпись переключателя.
Связанные объяснения: сортировка, LIMIT и индексы, границы параметров prepare.
Продолжить каталог после последней пары цена + ID
У кнопки «Ещё товары» должен быть точный адрес продолжения. Одной цены недостаточно: у чая и какао она одинакова. Передаём пару из последней показанной строки — цена и уникальный ID.
Исходные данные. Первая страница имеет лимит 2: Чай/id 1/12000 и Какао/id 3/12000. Между запросами появляется Новинка/id 5/11000. Вторая страница продолжает после(12000,3) и возвращает Мёд/id 4/25000, Кофе/id 2/30000.
| Страница | id | Название | Цена в копейках | Есть следующая |
|---|---|---|---|---|
| 1 | 1 | Чай | 12000 | true |
| 1 | 3 | Какао | 12000 | true |
| 2 | 4 | Мёд | 25000 | false |
| 2 | 2 | Кофе | 30000 | false |
Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.
- Проверить размер страницы:1..50
- Взять лимит+1 строку в порядке price_cents,id
- Лишняя строка сообщает has_more и не показывается
- Курсор первой страницы = цена 12000,ID 3
- Следующий WHERE выбирает больший составной ключ
SELECT id,name,price_cents
FROM mysql107_app_products
WHERE price_cents > ? OR (price_cents = ? AND id > ?)
ORDER BY price_cents,id
LIMIT ?Условие состоит из двух случаев: цена больше последней либо цена та же и ID больше последнего. AND связывает равную цену с ID, OR объединяет случаи; скобки делают намерение явным. Последовательность параметров: цена, та же цена, ID, размер выборки. LIMIT запрашивает 3 строки при странице 2. Если третья нашлась, has_more=true, array_pop убирает её из вывода; продолжение берёт последнюю действительно показанную строку. Проверка 500 отклоняет непомерный лимит до выполнения. Пустую страницу функция также поддерживает, но вызывающий код не должен брать последний элемент пустого массива.
Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';
$details=[];
$db=app107Connect(); app107Catalog($db);
function productPage(mysqli $db,int $price,int $id,int $limit): array {
if ($limit<1 || $limit>50 || $price<0 || $id<0) { throw new InvalidArgumentException('Invalid page boundary'); }
$take=$limit+1;
$statement=$db->prepare('SELECT id,name,price_cents FROM mysql107_app_products WHERE price_cents > ? OR (price_cents = ? AND id > ?) ORDER BY price_cents,id LIMIT ?');
$statement->bind_param('iiii',$price,$price,$id,$take); $statement->execute();
$result=$statement->get_result(); $records=$result->fetch_all(MYSQLI_ASSOC); $result->free(); $statement->close();
$more=count($records)>$limit;
if ($more) { array_pop($records); }
return ['records'=>$records,'has_more'=>$more];
}
try {
$first=productPage($db,0,0,2); $last=$first['records'][count($first['records'])-1];
$db->query("INSERT INTO mysql107_app_products (id,name,price_cents,stock) VALUES (5,'Новинка',11000,2)");
$second=productPage($db,(int)$last['price_cents'],(int)$last['id'],2);
$rows=[];
foreach ([1=>$first,2=>$second] as $page=>$data) {
foreach ($data['records'] as $record) { $rows[]=[$page,(int)$record['id'],$record['name'],(int)$record['price_cents'],$data['has_more']]; }
}
$rejected=false; try { productPage($db,0,0,500); } catch (InvalidArgumentException $error) { $rejected=true; }
$allIds=array_column($rows,1);
$checks=['stable_tie_break'=>$allIds===[1,3,4,2],'no_repeats'=>count(array_unique($allIds))===4,'flags'=>$first['has_more'] && !$second['has_more'],'page_size_bounded'=>$rejected];
$details=['inserted_before_cursor_id'=>5,'cursor'=>['price_cents'=>(int)$last['price_cents'],'id'=>(int)$last['id']],'snapshot_claimed'=>false];
$runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-page-cursor', ['Страница', 'id', 'Название', 'Цена в копейках', 'Есть следующая'], $rows, $checks, $runtime, $details);
echo "cursor pages: 1,3 then 4,2; inserted earlier item is outside continuation\n";
Ожидаемый результат
cursor pages: 1,3 then 4,2; inserted earlier item is outside continuation
Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.
Границы и ошибки. Новинка перед курсором не входит во вторую страницу: это живой каталог, не неизменный снимок. Изменение сортировочной цены уже показанного товара тоже способно повлиять на обход. Для выгрузки со строгим снимком нужен другой договор. В реальном API проверяйте структуру/типы курсора и связывайте его с теми же фильтрами/сортировкой; подпись курсора может предотвращать изменение параметров, но не заменяет ограничение доступа. Ограничения OFFSET и сравнение полной выборки со страницей на 100000 строк разобраны в предыдущей SQL-главе; здесь учимся протоколу продолжения.
Попробуйте сами. Поставьте лимит 3. Ожидаемая первая страница 1,3,4; продолжение после(25000,4) даёт 2. Решение: меняется размер, а не условия равной цены и ID.
Связанные объяснения: большая выдача и стабильная страница, keyset и ограничения выгрузки.
Собрать из фильтров один понятный запрос
Следующий самостоятельный шаг: соедините минимальный остаток, список выбранных ID и разрешённую сортировку. Не начинайте со склеивания произвольного SQL: каждое условие должно быть заранее известным фрагментом, каждое значение — отдельным параметром. Определите смысл пустого выбора; ограничьте размер выдачи; верните человеку понятное сообщение при отсутствии совпадений.
Для каталога с большими данными вернитесь к проекции, индексу и EXPLAIN. Для сохранения карточки переходите к ID, конфликтам и доступу.
Источники и обсуждения
- PHP: Prepared Statements
- PHP: mysqli_stmt::bind_param
- PHP: mysqli_stmt::execute
- PHP: mysqli_stmt::$affected_rows
- PHP: mysqli::$insert_id
- PHP: mysqli::real_connect
- MySQL8.4: PREPARE Statement
- MySQL8.4: String Comparison Functions and Operators
- MySQL8.4: Comparison Functions and Operators
- MySQL8.4: UPDATE Statement
- MySQL8.4: FOREIGN KEY Constraints
- MySQL8.4: Locks Set by Different SQL Statements in InnoDB
- MySQL8.4: Server Error Message Reference
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


