Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

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

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 102 · После первого SQL и параметризованного запроса

MySQL: подготовленные фильтры, буквальный LIKE, сортировка и курсор

От одного параметра остатка до списка ID, NULL, буквальных спецсимволов, допустимых сортировок и продолжения живого каталога.

От выбора пользователя к конкретным строкам каталога

В прошлых главах мы подключились к серверу, создали каталог и получили строки. Теперь данные запроса приходят из решений пользователя: выбранные товары, текст поиска, порядок сортировки и кнопка «Ещё». Научимся превращать каждое решение в понятный SQL-договор. Результат — точные наборы товаров и объяснимый отказ на неподходящий ввод, а не строка «запрос выполнен».

Перед началом нужны соединение и первая таблица, отделение SQL от значений и WHERE, ORDER BY и LIMIT. Здесь повторим смысл параметра, но не будем заново строить сервер. Шесть самостоятельных программ не зависят от запуска друг друга.

idТоварЦена, коп.Остаток
1Чай1200012
2Кофе300000
3Какао120007
4Мёд250004

Это общий каталог для четырёх сценариев. Цена хранится целым числом копеек:12000 означает 120 рублей, а не 12000 рублей. У чая и какао цена одинакова — поэтому сортировка и курсор используют также ID. Для NULL и LIKE специально создаются другие, явно показанные исходные строки.

Данные формы не становятся SQL-командой
  1. Пользователь выбирает порог 5
  2. PHP проверяет смысл/тип значения
  3. Фиксированный SQL содержит stock >= ?
  4. Параметр 5 передаётся отдельно
  5. MySQL возвращает id 1 иid 3
  6. 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 включает и распроданный кофе: это осознанное правило фильтра, а не пропуск проверки.

Минимальный остатокТоваровНазвания
04Чай, Кофе, Какао, Мёд
52Чай, Какао
200(пустая строка)

Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.

Как получается именно этот результат
  1. prepare: SELECT id,name WHERE stock >= ?
  2. bind_param: место ? связано с $minimum
  3. execute при 0 → четыре товара
  4. Та же переменная получает 5 → execute → Чай и Какао
  5. Значение 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.

Параметр noteid найденной строкиsku как строка
NULL10007
(пустая строка)20008
030009

Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.

Как получается именно этот результат
  1. PHP null передаётся параметром → SQL NULL
  2. 0007 передаётся как строка → остаётся 0007
  3. note <=> null → толькоid 1
  4. note <=> пустая строка → толькоid 2
  5. 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. Это сохранённый результат программы, не интерактивная форма.

Передать NULL и сохранить ведущие нули кода товараСамостоятельный пример
<?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.

Как получается именно этот результат
  1. Проверить каждый ID и ограничить количество 20
  2. Убрать повторы, восстановить последовательные ключи массива
  3. Для двух ID создать только синтаксис ?,?
  4. Привязать два целых значения; выполнить IN (?,?)
  5. 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. Это сохранённый результат программы, не интерактивная форма.

Выбрать несколько ID: отдельный параметр на каждое значениеСамостоятельный пример
<?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_B1
!%!!%Знак !1

Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.

Как получается именно этот результат
  1. Пользовательский текст 10%
  2. strtr экранирует именно синтаксис LIKE →10!%
  3. Добавляем внешние% →%10!%%
  4. Передаём готовый шаблон одним параметром
  5. 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_asc1,3,4,2разрешено
price_desc2,4,1,3разрешено
неизвестная SQL-строкане выполняласьотклонено

Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.

Как получается именно этот результат
  1. Интерфейс передаёт короткий ключ price_asc
  2. PHP ищет ключ в фиксированном словаре
  3. Из словаря берётся заранее написанное price_cents ASC,id ASC
  4. SELECT выбирает явный список колонок
  5. Неизвестный ключ → понятный отказ до 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НазваниеЦена в копейкахЕсть следующая
11Чай12000true
13Какао12000true
24Мёд25000false
22Кофе30000false

Это ожидаемая таблица сценария. Полный код ниже создаёт исходные записи сам, получает ответ MySQL и сохраняет такую же таблицу в result.html и result.json.

Как получается именно этот результат
  1. Проверить размер страницы:1..50
  2. Взять лимит+1 строку в порядке price_cents,id
  3. Лишняя строка сообщает has_more и не показывается
  4. Курсор первой страницы = цена 12000,ID 3
  5. Следующий 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. Это сохранённый результат программы, не интерактивная форма.

Продолжить каталог после последней пары цена + IDСамостоятельный пример
<?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, конфликтам и доступу.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.