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

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

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

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

Глава 103 · После таблицы, UPDATE и понимания параметров

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

Получить ID вставки, различить совпадения и изменения, сохранить чужую правку, обработать UNIQUE/FK и ограничить чтение и запись владельцем.

Что означает результат сохранения для человека

Успех SQL — только первый шаг. Для редактора важно, какой товар появился, действительно ли что-то изменилось, не устарела ли открытая форма и имеет ли он право сохранять эту строку. Следующие шесть программ показывают решения приложения после INSERT/UPDATE, включая ожидаемые отказы.

Сохранение карточки как проверяемое действие
  1. Доверенная личность + ID + новые поля + прочитанная версия
  2. Проверить данные, выбрать фиксированную команду
  3. WHERE ограничивает строку/владельца/версию
  4. MySQL применяет UNIQUE/FK и выполняет изменение
  5. ID/affected_rows/код отказа → решение приложения
  6. Показать сохранённые данные либо понятный конфликт

Начинаем с одного автоматически выданного ID. Затем различим matched/modified, воспроизведём конфликт двух редакторов и обработаем ограничения. Последний сценарий отдельно проверяет доступ к строке. Это развитие первого UPDATE и транзакции и схемы с ключами, а не новый дублирующий справочник API. Настройка среды и общие файлы описаны в начале блока.

Получить ID созданной записи и открыть именно её

После кнопки «Создать товар» приложение должно открыть созданную карточку. Запрос MAX(id) не говорит, кто вставил последнюю строку: другой пользователь мог успеть раньше вашего чтения. Нужен ID именно вашей вставки.

Исходные данные. Пустая учебная таблица выдаёт AUTO_INCREMENT ID двум отдельным INSERT: сначала TEA-001/Чай, затем COF-001/Кофе. Сразу после каждого выполнения читаем insert_id и используем его в SELECT.

Полученный IDСохранённый SKUПрочитанное название
1TEA-001Чай
2COF-001Кофе

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

Как получается именно этот результат
  1. INSERT создаёт одну запись
  2. Сразу сохранить $db->insert_id
  3. Передать сохранённый ID в отдельный SELECT
  4. Прочитать фактические sku/name
  5. Показать созданную карточку по этому ключу
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
$insert->execute();
$newId = $db->insert_id;
$find = $db->prepare('SELECT id,sku,name FROM mysql107_app_new_products WHERE id=?');
$find->bind_param('i', $newId);
$find->execute();

AUTO_INCREMENT генерирует значение для новой строки, если его не передали явно. insert_id — свойство без круглых скобок, относящееся к этому соединению. Прочитайте и сохраните его сразу после успешного INSERT, до последующих команд. Значение не нужно угадывать по числу строк таблицы. В полном примере find переиспользуется, но newId меняется после каждой вставки. Ожидаемые 1/2 относятся к созданной с нуля учебной таблице; в рабочей базе интересует выданный сервером ключ, а не его конкретное число.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Получить ID созданной записи и открыть именно еёСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$db=app107Connect();
try {
    $db->query('CREATE TEMPORARY TABLE mysql107_app_new_products (id INT AUTO_INCREMENT PRIMARY KEY, sku VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $insert=$db->prepare('INSERT INTO mysql107_app_new_products (sku,name) VALUES (?,?)');
    $sku=''; $name=''; $insert->bind_param('ss',$sku,$name);
    $find=$db->prepare('SELECT id,sku,name FROM mysql107_app_new_products WHERE id=?');
    $newId=0; $find->bind_param('i',$newId); $rows=[];
    foreach ([['TEA-001','Чай'],['COF-001','Кофе']] as $input) {
        [$sku,$name]=$input; $insert->execute();
        $newId=(int)$db->insert_id;
        $find->execute(); $result=$find->get_result(); $saved=$result->fetch_assoc(); $result->free();
        $rows[]=[$newId,$saved['sku'],$saved['name']];
    }
    $find->close(); $insert->close();
    $checks=['ids_read_immediately'=>array_column($rows,0)===[1,2],'right_records'=>$rows===[[1,'TEA-001','Чай'],[2,'COF-001','Кофе']]];
    $runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-insert-identity', ['Полученный ID', 'Сохранённый SKU', 'Прочитанное название'], $rows, $checks, $runtime, $details);
echo "inserted rows reopened by their own IDs: 1, 2\n";
Ожидаемый результат
inserted rows reopened by their own IDs: 1, 2

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. AUTO_INCREMENT может иметь пропуски после неудачных операций/откатов и не является счётчиком количества товаров. При INSERT нескольких строк insert_id возвращает первый сгенерированный ключ, не последний и не универсальный список всех ID. В PHP значение бывает int или string при выходе за диапазон int; здесь INT и малые значения допускают (int). Для больших unsigned BIGINT сохраняйте строковый договор. Полученный ID сам по себе не даёт право открыть чужую карточку.

Попробуйте сами. Создайте третью запись SKU HONEY-001 и откройте её по сохранённому insert_id. Ожидаемое имя — Мёд; не вычисляйте id как предыдущий+1.

Связанные объяснения: mysqli_insert_id, первичный ключ и схема.

Разобрать повторное сохранение, изменение и отсутствующий ID

Менеджер нажал «Сохранить», не изменив название. Нулевой affected_rows не обязательно означает ошибку или отсутствие товара. Чтобы выбрать правильное сообщение интерфейса, нужно знать, что именно считает текущее соединение.

Исходные данные. В каждом из двух независимых соединений создаём свой товар Чай/id 1. Последовательно сохраняем то же название, другое название и несуществующий id 99. Единственное различие — флаг MYSQLI_CLIENT_FOUND_ROWS при подключении.

ПодключениеТо же названиеДругое названиеID=99 отсутствуетСохранённое название
обычный режим010Чай зелёный
FOUND_ROWS110Чай зелёный

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

Как получается именно этот результат
  1. Обычное соединение считает действительно изменённые строки
  2. То же значение →0; новое значение →1; нетID →0
  3. FOUND_ROWS считает совпавшие с WHERE строки
  4. То же значение →1; новое значение →1; нетID →0
  5. Обратное чтение подтверждает одинаковое конечное название
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
$update = $db->prepare('UPDATE mysql107_app_edit_flags SET name=? WHERE id=?');
$update->bind_param('si', $name, $id);
$update->execute();
$count = $update->affected_rows;

Флаг задаётся real_connect, поэтому его нельзя добавить задним числом к уже выполненному UPDATE. Он влияет на смысл счётчика, а не меняет текст UPDATE или сохранённые данные. affected_rows читаем у statement сразу после execute. Обычный ноль неоднозначен; интерфейс может отдельно проверить доступную строку и сравнить значения, но такая предварительная проверка не является защитой от конкуренции. В следующем сценарии версия увеличивается при каждом принятом сохранении, поэтому успешная запись действительно меняет строку.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Разобрать повторное сохранение, изменение и отсутствующий IDСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$rows=[]; $all=[];
foreach ([['обычный режим',0],['FOUND_ROWS',MYSQLI_CLIENT_FOUND_ROWS]] as [$label,$flags]) {
    $db=app107Connect($flags);
    try {
        $db->query('CREATE TEMPORARY TABLE mysql107_app_edit_flags (id INT PRIMARY KEY,name VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
        $db->query("INSERT INTO mysql107_app_edit_flags (id,name) VALUES (1,'Чай')");
        $update=$db->prepare('UPDATE mysql107_app_edit_flags SET name=? WHERE id=?');
        $name=''; $id=1; $update->bind_param('si',$name,$id); $counts=[];
        foreach ([['Чай',1],['Чай зелёный',1],['Любое название',99]] as [$name,$id]) { $update->execute(); $counts[]=$update->affected_rows; }
        $update->close();
        $saved=$db->query('SELECT name FROM mysql107_app_edit_flags WHERE id=1')->fetch_assoc()['name'];
        $rows[]=[$label,$counts[0],$counts[1],$counts[2],$saved]; $all[]=$counts;
        $runtime=app107Runtime($db);
    } finally { $db->close(); }
}
$checks=['changed_vs_matched'=>$all===[[0,1,0],[1,1,0]],'same_final_value'=>array_column($rows,4)===['Чай зелёный','Чай зелёный']];
$details=['two_separate_local_sessions'=>true,'each_session_has_its_own_temporary_table'=>true];
app107Report('mysql107-matched-modified', ['Подключение', 'То же название', 'Другое название', 'ID=99 отсутствует', 'Сохранённое название'], $rows, $checks, $runtime, $details);
echo "UPDATE counts: changed 0/1/0; matched 1/1/0; final values identical\n";
Ожидаемый результат
UPDATE counts: changed 0/1/0; matched 1/1/0; final values identical

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. Сетевая/SQL-ошибка здесь приходит исключением, не смешивается с нормальным числом 0. Для INSERT ... ON DUPLICATE KEY UPDATE есть особые значения счётчика; эту таблицу нельзя автоматически переносить на любой SQL. Количество изменённых строк не равно числу всех изменений связанных таблиц. Два TEMPORARY-каталога нужны только для честного сравнения флагов; это не два редактора одного товара.

Попробуйте сами. После изменения ещё раз сохраните Чай зелёный. Ожидается 0 в обычном режиме и 1 с FOUND_ROWS. Решение: добавьте повтор в список, не сбрасывая название в таблице.

Связанные объяснения: affected_rows, флаги подключения, первое знакомство со счётчиками UPDATE.

Не затереть правку другого редактора: два соединения и номер версии

Два редактора открыли одну карточку Чай. Первый изменил название, второй пока дописывал размер упаковки. Простое UPDATE WHERE id=1 затёрло бы первую правку. Сохраним номер прочитанной версии и разрешим запись только пока она не изменилась.

Исходные данные. Оба соединения читают name Чай/version 1. A сохраняет Чай зелёный и получаетversion 2. B пытается записать свой старый вариант с условиемversion 1, получает 0 строк и перечитывает карточку. Затем явно составляет согласованное название и сохраняетversion 3.

ДействиеИзменено строкНазвание после действияВерсия
A: версия11Чай зелёный2
B: устаревшая версия10Чай зелёный2
B: согласованная версия21Чай зелёный, 100 г3

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

Как получается именно этот результат
  1. A иB отдельно прочиталиversion 1
  2. A: WHERE id 1 AND version 1 → nameновый,version 2
  3. B: WHERE id 1 AND version 1 →0 строк
  4. B видит текущий текстA и собственную правку
  5. Осознанное объединение → WHERE version 2 →version 3
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
UPDATE mysql107_edit_versions
SET name=?, version=version+1
WHERE id=? AND version=?

Колонка version — обычное целое число нашей схемы, не специальное ключевое слово MySQL. Проверка старой версии и её увеличение находятся в одной UPDATE-команде: нельзя отдельно проверить SELECT, а затем безусловно перезаписать строку. Каждое принятое редактирование увеличивает version. affected_rows=1 означает, что условие совпало и строка действительно обновилась. Ноль означает конфликт, удалённую строку либо иной отказ условия; после безопасного перечитывания приложение решает, что показать. Полный пример имеет два реальных соединения, которые последовательно исполняют заранее заданное переплетение действий. Долгую транзакцию на время, пока человек думает над формой, мы не держим.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Не затереть правку другого редактора: два соединения и номер версииСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$a=app107Connect(); $b=null; $created=false;
try {
    $a->query('CREATE TABLE mysql107_edit_versions (id INT PRIMARY KEY,name VARCHAR(60) NOT NULL,version INT UNSIGNED NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $created=true;
    $a->query("INSERT INTO mysql107_edit_versions (id,name,version) VALUES (1,'Чай',1)");
    $b=app107Connect();
    $aSnapshot=$a->query('SELECT name,version FROM mysql107_edit_versions WHERE id=1')->fetch_assoc();
    $bSnapshot=$b->query('SELECT name,version FROM mysql107_edit_versions WHERE id=1')->fetch_assoc();
    $aVersion=(int)$aSnapshot['version']; $bVersion=(int)$bSnapshot['version'];
    $saveA=$a->prepare('UPDATE mysql107_edit_versions SET name=?,version=version+1 WHERE id=? AND version=?');
    $nameA='Чай зелёный'; $id=1; $saveA->bind_param('sii',$nameA,$id,$aVersion); $saveA->execute(); $changedA=$saveA->affected_rows; $saveA->close();
    $afterA=$a->query('SELECT name,version FROM mysql107_edit_versions WHERE id=1')->fetch_assoc();
    $saveB=$b->prepare('UPDATE mysql107_edit_versions SET name=?,version=version+1 WHERE id=? AND version=?');
    $nameB='Чай, 100 г'; $saveB->bind_param('sii',$nameB,$id,$bVersion); $saveB->execute(); $stale=$saveB->affected_rows;
    $current=$b->query('SELECT name,version FROM mysql107_edit_versions WHERE id=1')->fetch_assoc();
    // Explicit decision after showing both edits: retain A's change and add the size.
    $nameB='Чай зелёный, 100 г'; $bVersion=(int)$current['version']; $saveB->execute(); $resolved=$saveB->affected_rows; $saveB->close();
    $final=$a->query('SELECT name,version FROM mysql107_edit_versions WHERE id=1')->fetch_assoc();
    $rows=[['A: версия1',$changedA,$afterA['name'],(int)$afterA['version']],['B: устаревшая версия1',$stale,$current['name'],(int)$current['version']],['B: согласованная версия2',$resolved,$final['name'],(int)$final['version']]];
    $checks=['two_real_connections'=>$a->thread_id!==$b->thread_id,'both_started_at_one'=>$aVersion===1 && (int)$bSnapshot['version']===1,'stale_edit_refused'=>$changedA===1 && $stale===0,'first_edit_preserved'=>$current['name']==='Чай зелёный','explicit_resolution'=>$resolved===1 && $final['name']==='Чай зелёный, 100 г' && (int)$final['version']===3];
    $details=['interleaving'=>'A reads; B reads; A commits autocommit UPDATE; B stale UPDATE; B reads and explicitly resolves','load_test'=>false];
    $runtime=app107Runtime($a);
} finally {
    if ($b instanceof mysqli) { $b->close(); }
    if ($created) { $a->query('DROP TABLE mysql107_edit_versions'); }
    $a->close();
}
app107Report('mysql107-optimistic-version', ['Действие', 'Изменено строк', 'Название после действия', 'Версия'], $rows, $checks, $runtime, $details);
echo "optimistic edit: A saved; stale B refused; explicit merged edit saved at version 3\n";
Ожидаемый результат
optimistic edit: A saved; stale B refused; explicit merged edit saved at version 3

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. Это воспроизведение конфликта через две сессии, не параллельный нагрузочный тест. Autocommit завершает каждый UPDATE; InnoDB всё равно использует внутренние блокировки при записи. Слово «оптимистический» не означает отсутствие любых блокировок. Автоматически брать новую версию и слепо повторять прежнее название нельзя: это опять потеряет чужой смысл. Все пути изменения должны соблюдать договор version; импорт/фоновая задача, которые его не увеличивают, нарушат защиту. Для связанного изменения нескольких таблиц дополнительно нужна транзакция.

Попробуйте сами. После чтения B удалите товар отдельным разрешённым действием. Попытка записи вернёт 0, но перечитывание не найдёт строку: сообщите «товар удалён», не предлагайте несуществующий текст для объединения. Это вариант задачи, не дополнительный выполненный сценарий.

Связанные объяснения: несколько связанных записей и блокировки, что объединяет транзакция, affected_rows.

Ситуация встречается в вопросе Optimistic locking in MySQL: Автор видит конфликт двух редактирований и спрашивает, является ли оптимистическая проверка особой SQL-командой или правилом приложения поверх транзакций. Обсуждение служит источником практической задачи; код и объяснение написаны заново, технический договор сверяется с PHP/MySQL.

Вернуть понятный отказ при занятом артикуле

Два товара не должны получать один SKU. Проверка SELECT перед INSERT полезна для ранней подсказки, но между ними другой запрос может занять артикул. Окончательное правило должна обеспечивать UNIQUE-колонка.

Исходные данные. Первый TEA-001 сохраняется с названием Чай. Второй INSERT того же SKU пытается записать Другой товар: база отвергает дубликат, первоначальная запись остаётся. Пользователь выбирает TEA-002, и новая строка успешно добавляется.

SKUПопыткаОтвет приложенияСохранённое название
TEA-001первая записьсохраненоЧай
TEA-001повторный артикулартикул уже занятЧай
TEA-002исправленный артикулсохраненоЧай чёрный

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

Как получается именно этот результат
  1. UNIQUE на sku задаёт правило базы
  2. INSERT TEA-001 → успешно
  3. Повтор TEA-001 → ошибка 1062
  4. Приложение показывает «артикул уже занят»
  5. Новый SKU TEA-002 → успешная отдельная вставка
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
try {
    $insert->execute();
} catch (mysqli_sql_exception $error) {
    if ($error->getCode() !== 1062) { throw $error; }
    $message = 'Артикул уже занят';
}

В примере 1062 перехватывается только вокруг конкретного INSERT и только как ожидаемый конфликт уникального ключа. Другие коды повторно выбрасываются, чтобы не сообщать о «занятом артикуле» при сетевом отказе или неверном SQL. Текст SQL-исключения не выводится посетителю: он может содержать имена таблиц и данные. Диагностика сохраняет код и SQLSTATE; пользователь получает понятное действие. В этой таблице у запроса только один вводимый уникальный бизнес-ключ sku, поэтому 1062 можно связать с ним. При нескольких UNIQUE нужно различать, какое правило нарушено.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Вернуть понятный отказ при занятом артикулеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$db=app107Connect();
try {
    $db->query('CREATE TEMPORARY TABLE mysql107_app_unique_sku (id INT AUTO_INCREMENT PRIMARY KEY,sku VARCHAR(20) COLLATE utf8mb4_0900_as_cs NOT NULL UNIQUE,name VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $insert=$db->prepare('INSERT INTO mysql107_app_unique_sku (sku,name) VALUES (?,?)');
    $sku='TEA-001'; $name='Чай'; $insert->bind_param('ss',$sku,$name); $insert->execute();
    $code=0; $state=''; $name='Другой товар';
    try { $insert->execute(); } catch (mysqli_sql_exception $error) {
        if ($error->getCode()!==1062) { throw $error; }
        $code=$error->getCode(); $state=$error->getSqlState();
    }
    $saved=$db->query("SELECT name FROM mysql107_app_unique_sku WHERE sku='TEA-001'")->fetch_assoc()['name'];
    $sku='TEA-002'; $name='Чай чёрный'; $insert->execute(); $insert->close();
    $total=(int)$db->query('SELECT COUNT(*) AS total FROM mysql107_app_unique_sku')->fetch_assoc()['total'];
    $rows=[['TEA-001','первая запись','сохранено','Чай'],['TEA-001','повторный артикул','артикул уже занят',$saved],['TEA-002','исправленный артикул','сохранено','Чай чёрный']];
    $checks=['duplicate_detected'=>$code===1062 && $state==='23000','original_untouched'=>$saved==='Чай','two_products'=>$total===2];
    $details=['controlled_error_code'=>$code,'controlled_sqlstate'=>$state,'public_error_text'=>'Артикул уже занят','auto_increment_contiguity_assumed'=>false];
    $runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-duplicate-sku', ['SKU', 'Попытка', 'Ответ приложения', 'Сохранённое название'], $rows, $checks, $runtime, $details);
echo "duplicate SKU refused; original preserved; corrected SKU inserted\n";
Ожидаемый результат
duplicate SKU refused; original preserved; corrected SKU inserted

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. INSERT IGNORE может скрывать другие проблемы и не является универсальным исправлением конфликта. REPLACE способен удалить старую строку и вставить новую; это другой бизнес-смысл. Здесь конфликт не должен менять старый товар, поэтому выбирается обычный INSERT и явный отказ. Для SKU намеренно выбран регистрозависимый collation utf8mb4_0900_as_cs; нужно заранее решить, считаются ли tea-001 и TEA-001 одинаковыми, и привести в согласие нормализацию и индекс. После неудачи AUTO_INCREMENT может пропустить номер; пример не требует последовательных ID.

Попробуйте сами. Разрешите только заглавные латинские буквы, цифры и дефис в SKU на входе. Решение: валидируйте формат и явно нормализуйте регистр до INSERT, сохранив UNIQUE как финальную защиту. Пустой SKU отклоняйте отдельно.

Связанные объяснения: ограничения UNIQUE, повторные импорты и конфликты.

Объяснить пользователю две разные ошибки связи

Редактор выбрал категорию, которую уже удалили, или пытается удалить категорию с товарами. Оба действия нарушают связь, но пользователю нужны разные объяснения и разные следующие шаги.

Исходные данные. Есть категория 1 Напитки и товар 1 Чай. Попытка добавить товар 2 в категорию 99 отклоняется. Исправленный category_id 1 сохраняет кофе. Затем удаление категории 1 запрещается, потому что на неё уже ссылаются два товара.

ЗапросКод MySQLПонятный ответТоваров в таблице
Товар с category_id=991452Выберите существующую категорию1
Повтор с category_id=10Товар сохранён2
Удаление используемой категории1451Сначала перенесите или удалите её товары2

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

Как получается именно этот результат
  1. categories.id 1 ← catalog.category_id 1
  2. Новая строка сcategory 99 → нет родителя →1452
  3. Повтор сcategory 1 → связь корректна
  4. DELETE категории 1 → дочерние строки существуют →1451
  5. Показать подходящий отказ, сохранить существующие данные
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
$category = 99;
$add = $db->prepare('INSERT INTO mysql107_edit_catalog (id,category_id,name) VALUES (?,?,?)');
$add->bind_param('iis', $id, $category, $name);
$add->execute(); // Категория99 отсутствует: исключение с кодом1452.

FOREIGN KEY хранит проверяемую связь с родительским ключом. Имена колонок сами по себе отношения не создают; ограничение объявлено в CREATE TABLE. ON DELETE RESTRICT говорит серверу отказать в удалении используемой категории. В обработке INSERT ожидаем 1452, в обработке DELETE —1451, остальные ошибки не маскируем. После исправления ввода выполняется новая обычная операция. Не создаём случайную категорию 99 автоматически и не выключаем FOREIGN_KEY_CHECKS ради прохождения формы. Счётчики в результате показывают, что отказанная вставка не увеличила число товаров, а отказанное удаление не уничтожило категорию.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Объяснить пользователю две разные ошибки связиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$db=app107Connect(); $created=[];
try {
    $db->query('CREATE TABLE mysql107_edit_categories (id INT PRIMARY KEY,name VARCHAR(60) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $created[]='mysql107_edit_categories';
    $db->query('CREATE TABLE mysql107_edit_catalog (id INT PRIMARY KEY,category_id INT NOT NULL,name VARCHAR(60) NOT NULL,CONSTRAINT mysql107_edit_catalog_category_fk FOREIGN KEY (category_id) REFERENCES mysql107_edit_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $created[]='mysql107_edit_catalog';
    $db->query("INSERT INTO mysql107_edit_categories (id,name) VALUES (1,'Напитки')");
    $db->query("INSERT INTO mysql107_edit_catalog (id,category_id,name) VALUES (1,1,'Чай')");
    $add=$db->prepare('INSERT INTO mysql107_edit_catalog (id,category_id,name) VALUES (?,?,?)');
    $id=2; $category=99; $name='Кофе'; $add->bind_param('iis',$id,$category,$name); $missing=0;
    try { $add->execute(); } catch (mysqli_sql_exception $error) { if ($error->getCode()!==1452) { throw $error; } $missing=1452; }
    $before=(int)$db->query('SELECT COUNT(*) AS total FROM mysql107_edit_catalog')->fetch_assoc()['total'];
    $category=1; $add->execute(); $add->close();
    $used=0;
    try { $db->query('DELETE FROM mysql107_edit_categories WHERE id=1'); } catch (mysqli_sql_exception $error) { if ($error->getCode()!==1451) { throw $error; } $used=1451; }
    $products=(int)$db->query('SELECT COUNT(*) AS total FROM mysql107_edit_catalog')->fetch_assoc()['total'];
    $categories=(int)$db->query('SELECT COUNT(*) AS total FROM mysql107_edit_categories')->fetch_assoc()['total'];
    $rows=[['Товар с category_id=99',$missing,'Выберите существующую категорию',$before],['Повтор с category_id=1',0,'Товар сохранён',$products],['Удаление используемой категории',$used,'Сначала перенесите или удалите её товары',$products]];
    $checks=['missing_parent_rejected'=>$missing===1452 && $before===1,'valid_retry_saved'=>$products===2,'used_parent_retained'=>$used===1451 && $categories===1];
    $details=['foreign_key_checks_disabled'=>false,'existing_schema_objects_deleted'=>false];
    $runtime=app107Runtime($db);
} finally {
    foreach (array_reverse($created) as $table) { $db->query('DROP TABLE `'.$table.'`'); }
    $db->close();
}
app107Report('mysql107-foreign-key-response', ['Запрос', 'Код MySQL', 'Понятный ответ', 'Товаров в таблице'], $rows, $checks, $runtime, $details);
echo "foreign keys: missing category refused; valid retry saved; used category retained\n";
Ожидаемый результат
foreign keys: missing category refused; valid retry saved; used category retained

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. Предварительный список категорий на странице может устареть: ограничение проверяется сервером в момент записи. CASCADE — другое сознательное правило, при котором удаление родителя затрагивает дочерние строки; не включайте его только ради исчезновения ошибки. Эти две таблицы постоянные, потому что нужны реальные InnoDB foreign keys; учебная программа удаляет только то, что сама успешно создала. Подробная модель связей и типы ключей разобраны в SQL-главе.

Попробуйте сами. Предложите перенос товаров в другую существующую категорию перед удалением старой. Решение: объедините UPDATE товаров и DELETE категории в транзакцию, проверьте права и ограничения, откатите обе операции при отказе. Реальная реализация переносов относится к следующему блоку транзакций.

Связанные объяснения: внешние ключи и отношения, целостная операция в транзакции.

Проверять владельца в чтении и в самой команде изменения

Пользователь может изменить ID в адресе или запросе вручную. Скрытой кнопки «Редактировать» недостаточно: сервер должен ограничить и чтение, и запись владельцем документа. Параметризованный SQL предотвращает подмену синтаксиса, но сам не проверяет право доступа.

Исходные данные. Доверенная личность из серверной аутентификации моделируется viewerId 7. Документ 1 принадлежит 7, документ 2 —8. Проверим свойID 1, чужойID 2 и отсутствующийID 99. Для двух последних публичный ответ одинаков: «Недоступно».

Запрошенный IDЧтение для пользователя7Изменено строкОтвет на сохранение
1Мой документ1Сохранено
2Недоступно0Недоступно
99Недоступно0Недоступно

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

Как получается именно этот результат
  1. Сервер определил аутентифицированного пользователя 7
  2. SELECT WHERE id=? AND owner_id=7
  3. UPDATE также содержит owner_id=7
  4. Свой документ →1 изменение
  5. Чужой/отсутствующий →0; чужой текст не раскрывается
Ключевой фрагмент полного примера; соединение и исходные таблицы уже подготовлены
UPDATE mysql107_app_documents
SET title=?, version=version+1
WHERE id=? AND owner_id=?

owner_id берётся из доверенного серверного контекста после проверки сессии/токена. Его нельзя копировать из скрытого поля формы или принимать как право, переданное клиентом. ID документа — только запрошенный ресурс. Даже если клиент обойдёт предварительное чтение, условие владельца остаётся в самой UPDATE. В учебной записи version увеличивается, поэтому принятое обновление учитывается даже при прежнем тексте. Проверочная программа затем внутренне читает фикстуры, чтобы доказать неизменность чужого документа; этот безусловный тестовый SELECT не является публичным endpoint. В таблицу результата чужое название не попадает.

Открыть HTML-таблицу фактического запуска · Скачать значения JSON. Это сохранённый результат программы, не интерактивная форма.

Проверять владельца в чтении и в самой команде измененияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/app107-helper.php';

$details=[];
$db=app107Connect();
try {
    $db->query('CREATE TEMPORARY TABLE mysql107_app_documents (id INT PRIMARY KEY,owner_id INT NOT NULL,title VARCHAR(80) NOT NULL,version INT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    $db->query("INSERT INTO mysql107_app_documents (id,owner_id,title,version) VALUES (1,7,'Мой черновик',1),(2,8,'Чужой черновик',1)");
    // Simulated trusted authenticated identity, NOT a field from the submitted request.
    $viewerId=7;
    $find=$db->prepare('SELECT id,title,version FROM mysql107_app_documents WHERE id=? AND owner_id=?');
    $id=0; $find->bind_param('ii',$id,$viewerId);
    $update=$db->prepare('UPDATE mysql107_app_documents SET title=?,version=version+1 WHERE id=? AND owner_id=?');
    $title=''; $update->bind_param('sii',$title,$id,$viewerId); $rows=[]; $outcomes=[];
    foreach ([[1,'Мой сохранённый документ'],[2,'Попытка чужой правки'],[99,'Несуществующий документ']] as [$id,$title]) {
        $find->execute(); $result=$find->get_result(); $visible=$result->fetch_assoc(); $result->free();
        // Even if someone bypasses the read/UI, UPDATE carries the same owner predicate.
        $update->execute(); $changed=$update->affected_rows; $outcomes[]=$changed;
        $rows[]=[$id,$visible===null?'Недоступно':'Мой документ',$changed,$changed===1?'Сохранено':'Недоступно'];
    }
    $find->close(); $update->close();
    // The test-only inspection below checks isolation; it is not a public read endpoint.
    $own=$db->query('SELECT title,version FROM mysql107_app_documents WHERE id=1')->fetch_assoc();
    $other=$db->query('SELECT title,version FROM mysql107_app_documents WHERE id=2')->fetch_assoc();
    $checks=['only_owned_row_changed'=>$outcomes===[1,0,0],'own_saved'=>$own['title']==='Мой сохранённый документ' && (int)$own['version']===2,'other_untouched'=>$other['title']==='Чужой черновик' && (int)$other['version']===1,'same_public_denial'=>$rows[1][1]===$rows[2][1] && $rows[1][3]===$rows[2][3]];
    $details=['authentication_implemented'=>false,'trusted_viewer_fixture'=>7,'foreign_title_in_public_rows'=>false];
    $runtime=app107Runtime($db);
} finally { $db->close(); }
app107Report('mysql107-owner-constrained-edit', ['Запрошенный ID', 'Чтение для пользователя7', 'Изменено строк', 'Ответ на сохранение'], $rows, $checks, $runtime, $details);
echo "owner policy: own row saved; foreign and absent rows unavailable\n";
Ожидаемый результат
owner policy: own row saved; foreign and absent rows unavailable

Полный самостоятельный комплект: example.php, app107-helper.php, README.txt. Сначала задайте локальное окружение из инструкции главы. Затем php example.php создаст исходные данные, выполнит сценарий и запишет HTML/JSON. Helper только подключается по окружению, создаёт общий учебный каталог там, где он нужен, и сохраняет отчёт; SQL фильтра/редактирования виден в example.php. Проверки в конце нужны для воспроизводимости урока, а не являются обязательным синтаксисом каждого запроса.

Границы и ошибки. Это CLI-проверка ограничения строки, не реализованная авторизация сайта. Настоящему приложению дополнительно нужны проверка личности, CSRF-защита изменяющей формы, валидация текста, политика ролей и журналирование. Одинаковый отказ для чужого/несуществующего ID уменьшает раскрытие наличия документов, но не доказывает отсутствие временных побочных каналов. В проекте с общей организацией owner_id может замениться tenant_id/проверкой членства; право должно быть применено ко всем путям чтения/изменения. Для защиты от двух редакторов добавьте условие version по предыдущему уроку.

Попробуйте сами. Разрешите владельцу удалить собственный документ. Решение: DELETE WHERE id=? AND owner_id=?, проверка affected_rows и такое же публичное сообщение для чужого/отсутствующего ID. Не переносите удаление в GET-запрос.

Связанные объяснения: откуда берётся доверенная личность, проверка доступа и защита формы, конфликт версии.

Когда одной команды сохранения недостаточно

Одно условное UPDATE защищает конкретную строку по заданному договору. Когда требуется списать остаток, добавить заказ и его позиции целиком, нужна короткая транзакция и выбор блокировок. Когда данные приходят файлом и допускают повторную доставку, нужны валидация импорта и восстановление после отказа.

Не превращайте любой код исключения в одинаковое «не сохранено». Отделяйте ошибку ввода, отсутствие доступа, устаревшую версию, конфликт уникального ключа и отказ сервера. Показывайте человеку следующий доступный шаг, а технические сообщения оставляйте в закрытой диагностике.

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

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

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

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

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