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

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

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

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

Глава 141 · После таблиц, INSERT/UPDATE, параметров и транзакций

MySQL: миграции заполненной таблицы, backfill и восстановление шага

Совместимое поле, возобновляемые порции, реальные отказы NOT NULL / UNIQUE / FK и журнал, сверяющий фактическую схему.

Изменить заполненный каталог по шагам

Каталог уже содержит товары, а новая версия сайта требует дополнительное поле, обязательное значение или надёжную связь. Миграция схемы — последовательность изменений структуры и данных, переводящая такую базу в новое согласованное состояние. На маленьком каталоге увидим, как сохранить прежние записи, перенести значения и включить правила только после подготовки данных.

Один переход каталога
  1. Старая products: 6 товаров, зоны нет
  2. Расширяем: delivery_region допускает NULL
  3. Заполняем старые строки порциями
  4. Проверяем NULL, дубли и ссылки
  5. Усиливаем правила; журнал помогает повтору

Представьте карточки товаров с добавленной графой «зона доставки»: сначала графа может быть пустой, затем значения переносят по правилу, потом заполнение становится обязательным. Аналогия объясняет порядок, но MySQL дополнительно проверяет типы, индексы и связи, а ALTER может перестраивать данные и ждать блокировки. Пример с UNIQUE и пример с FK — отдельные варианты развития этого каталога.

Нужны основы таблиц и ключей, параметров SQL и транзакций PDO. DDL — команды изменения структуры, например ALTER; DML — работа со строками, например UPDATE. На странице сначала показаны небольшие SQL-фрагменты и результаты, затем раскрывается самостоятельная программа. В новых строках SQL имена полей перечислены явно.

IDТоварskucitycategory_id
101Кружка белаяMUG-AМосква10
104Кружка синяяMUG-BКазань10
108Лампа малаяLAMP-AМосква20
115Лампа большаяLAMP-BСамара20
120Тарелка мелкаяPLATE-AКазань10
140Тарелка глубокаяPLATE-BМосква10

Каждый из шести архивов самостоятельно создаёт именно эти исходные строки. В уроке указывается, какие данные дополнительно изменены для проверки ошибки. Случайное имя защищает учебный набор от совпадения с существующим; на странице для читаемости используются короткие products, categories, progress, migrations.

Запустить миграцию в своей учебной базе

Нужны PHP 8.4+ с PDO_MYSQL/mysqlnd, MySQL 8.4 и отдельная локальная учебная база. Подключение задаётся через PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER и PHPMAN_MYSQL_PASSWORD по инструкции настройки. Контрольное окружение: PHP 8.5.11 / MySQL 8.4.13, 127.0.0.1:33070. Распакуйте один ZIP, выполните php example.php, откройте result.html.

Архив содержит example.php, se114-helper.php и README. Создаются только собственные обычные таблицы mysql114_schema_ со случайным суффиксом; CREATE не имеет IF NOT EXISTS, флаг владения появляется после успеха. finally удаляет продукты перед категориями, затем собственные служебные таблицы. Проверка по точным именам подтверждает очистку. Нужны CREATE, ALTER, SELECT, INSERT, UPDATE, INDEX, REFERENCES, DROP на учебную базу. Программа требует строгий режим и включённые FK, проверяет их, но не меняет настройки сервера.

Ссылки «фактический результат» показывают сохранённые HTML/JSON реального CLI-запуска. Сам сайт не запускает миграцию по открытию страницы. После запуска в metrics.json записаны версии, точные проверки и диагностические коды без паролей. Полная программа длиннее главного SQL, потому что включает начальные данные, проверку результата и уборку.

Добавить необязательную зону доставки и сохранить старый способ записи

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

Исходные данные

В products уже есть шесть товаров из таблицы выше. Поля delivery_region ещё нет. Старый INSERT перечисляет id, title, sku, city, category_id. Новый INSERT добавит delivery_region. Для старых товаров зона пока неизвестна; NULL честно обозначит это состояние.

Путь изменения и проверки
  1. 101 Кружка белая уже хранится в products
  2. ADD delivery_region: старые строки получают NULL
  3. 201 записывает прежний импорт: зона NULL
  4. 202 записывает новый импорт: зона Столица

Что получится

IDТоварdelivery_region
101Кружка белаяNULL
104Кружка синяяNULL
201Кружка зелёнаяNULL
202Кружка сераяСтолица

Главная запись SQL

ALTER TABLE products
    ADD COLUMN delivery_region VARCHAR(16) NULL DEFAULT NULL;

-- Старый код явно перечисляет прежние поля.
INSERT INTO products (id,title,sku,city,category_id)
VALUES (201,'Кружка зелёная','MUG-C','Москва',10);

-- Новый код умеет передавать зону.
INSERT INTO products (id,title,sku,city,category_id,delivery_region)
VALUES (202,'Кружка серая','MUG-D','Москва',10,'Столица');

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

ALTER TABLE products выбирает таблицу для изменения структуры; ADD COLUMN добавляет столбец. VARCHAR(16) хранит строку до 16 символов. NULL DEFAULT NULL разрешает отсутствие значения и задаёт его при пропуске поля. Это не пустая строка и не обещание доставить товар в какой-либо регион.

В результат выведены четыре показательных товара: два прежних, один от старого импортёра и один от нового. Проверка программы дополнительно сравнивает id/title/sku всех шести прежних товаров до и после ALTER. Сами строки не приходится удалять или создавать заново. Явный список столбцов INSERT позволяет старому коду продолжать отправлять только известные ему поля.

Переход удобно разделить: добавить необязательное поле → научить новую запись его заполнять → заполнить исторические строки → проверить данные → при необходимости сделать поле обязательным. Это называется расширением схемы с последующим ужесточением условий. Здесь наблюдаем первый шаг; следующие уроки показывают остальные на том же каталоге.

Границы и ошибки

Совместимость показана для INSERT с явным списком столбцов и чтения прежних полей. INSERT INTO products VALUES (...) без списка может сломаться из-за нового количества столбцов. Старый код, который требует фиксированную форму SELECT *, тоже нужно проверить. Новый столбец сам не вычисляет зону по городу. Возможность быстрого ADD зависит от конкретной таблицы и алгоритма; пример не измеряет длительность и не обещает отсутствие блокировок.

Полная самостоятельная программа и файлы
Добавить необязательную зону доставки и сохранить старый способ записиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
$oldBefore=se114Rows($db,"SELECT id,title,sku FROM $pt ORDER BY id");
    $db->exec("ALTER TABLE $pt ADD COLUMN delivery_region VARCHAR(16) NULL DEFAULT NULL");
    $oldAfter=se114Rows($db,"SELECT id,title,sku FROM $pt ORDER BY id");
    $initialNulls=se114Count($db,"SELECT COUNT(*) AS total FROM $pt WHERE delivery_region IS NULL");
    $db->prepare("INSERT INTO $pt (id,title,sku,city,category_id) VALUES (?,?,?,?,?)")->execute([201,'Кружка зелёная','MUG-C','Москва',10]);
    $db->prepare("INSERT INTO $pt (id,title,sku,city,category_id,delivery_region) VALUES (?,?,?,?,?,?)")->execute([202,'Кружка серая','MUG-D','Москва',10,'Столица']);
    $selected=se114Rows($db,"SELECT id,title,delivery_region FROM $pt WHERE id IN (101,104,201,202) ORDER BY id");
    $rows=array_map(fn(array $row)=>[(int)$row['id'],$row['title'],$row['delivery_region']],$selected);
    $checks=['six_old_values_preserved'=>$oldBefore===$oldAfter && count($oldAfter)===6,'old_rows_start_with_null'=>$initialNulls===6,'old_insert_still_works'=>$rows[2][2]===null,'new_insert_sets_region'=>$rows[3][2]==='Столица','eight_rows_preserved'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt")===8];
    $details=['old_row_count'=>count($oldBefore),'initial_null_count'=>$initialNulls,'new_column'=>se114Column($db,$products,'delivery_region')];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-compatible-column', ['ID', 'Товар', 'delivery_region'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-compatible-column: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-compatible-column: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Добавьте в новый INSERT зону «Регион» для товара из Казани. Ожидайте это значение только у новой строки; старые NULL не изменятся. Затем объясните, почему подстановка «Столица» как DEFAULT для всех неизвестных городов исказила бы данные.

Заполнить старые строки порциями и продолжить с сохранённого ID

Поле добавлено, но у старых товаров зона отсутствует. Вместо одной большой транзакции заполняем небольшие порции и сохраняем место продолжения. Это пригодится для переноса старого формата в новый, когда работу приходится запускать повторно.

Исходные данные

Те же шесть товаров; новый delivery_region сначала NULL. У ID 120 новая версия приложения уже записала «Особый»: это значение нужно сохранить. Для учебного правила Москва означает «Столица», Казань и Самара — «Регион». Таблица progress хранит одну строку: job=delivery_region, last_id=0.

Путь изменения и проверки
  1. Курсор 0 → выбираем ID 101, 104
  2. UPDATE только NULL + checkpoint 104 → один COMMIT
  3. Управляемая пауза → перечитываем checkpoint 104
  4. ID 108,115 затем 120,140; Особый у 120 сохраняется
  5. После 140 следующая порция пуста: завершение

Что получится

ШагID / курсорРезультат
После первой порции104Осталось NULL: 3
Продолжение с checkpoint104Изменено строк: 3
Повтор после завершения140Изменено строк: 0
Товар101Столица
Товар104Регион
Товар108Столица
Товар115Регион
Товар120Особый
Товар140Столица

Главная запись SQL

-- Одна порция: данные и checkpoint меняются одной транзакцией.
START TRANSACTION;
SELECT last_id FROM progress WHERE job='delivery_region' FOR UPDATE;
SELECT id FROM products WHERE id>104 ORDER BY id LIMIT 2;
-- Получены 108 и 115; для каждого такого ID:
UPDATE products
SET delivery_region=CASE WHEN city='Москва' THEN 'Столица' ELSE 'Регион' END
WHERE id=108 AND delivery_region IS NULL;
-- Аналогичный UPDATE для 115.
UPDATE progress SET last_id=115 WHERE job='delivery_region';
COMMIT;

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

Backfill — заполнение исторических строк данными, которые новая версия уже умеет записывать. Checkpoint — сохранённая отметка пройденного места. Здесь отметка — первичный ключ последнего просмотренного товара, а не номер страницы. Пробелы между 104 и 108 не мешают условию id > last_id.

Функция se114BackfillBatch(..., 2) открывает транзакцию, читает checkpoint, выбирает максимум два ID по возрастанию, обновляет их и сохраняет новый checkpoint. FOR UPDATE удерживает строку отметки до конца этой транзакции. Значения и отметка фиксируются одним COMMIT: при обычной ошибке до него откатываются оба изменения. Внутри этой транзакции нет ALTER.

WHERE delivery_region IS NULL сохраняет уже заполненное значение «Особый». Курсор всё равно проходит ID 120, иначе обработчик мог бы застрять на готовой строке. Параметр LIMIT передаётся целым числом через PDO::PARAM_INT. Функция возвращает просмотренные ID, последний ID и число действительно изменённых строк.

После первой порции программа намеренно возвращает управление, считывает checkpoint из таблицы и вызывает функцию снова. Она не уничтожает процесс и не имитирует потерю питания. После последней порции повторный вызов возвращает пустой список и updated=0: результат уже получен, повторная работа не нужна. Повторяемая обработка импорта помогает отделить похожий вопрос повторного бизнес-события от переноса полей.

Границы и ошибки

Два — учебный размер порции, не универсальная настройка. Выборка ограничивает число строк, но не гарантирует конкретное время или нагрузку. Здесь один исполнитель и фиксированный набор данных; изменение city после обработки или добавление NULL со старым ID потребуют отдельной политики. В действующей системе прежде включают корректную новую запись, затем backfill и финальный поиск пропусков. Блокировка строки progress не превращает весь процесс в готовый оркестратор миграций.

Полная самостоятельная программа и файлы
Заполнить старые строки порциями и продолжить с сохранённого IDСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
$db->exec("ALTER TABLE $pt ADD COLUMN delivery_region VARCHAR(16) NULL DEFAULT NULL");
    $db->prepare("UPDATE $pt SET delivery_region=? WHERE id=?")->execute(['Особый',120]);
    se114Create($db,$progress,'job VARCHAR(40) NOT NULL PRIMARY KEY,last_id INT UNSIGNED NOT NULL',$created);
    $jt=se114Name($progress); $db->prepare("INSERT INTO $jt (job,last_id) VALUES (?,?)")->execute(['delivery_region',0]);
    $first=se114BackfillBatch($db,$products,$progress,2);
    $cursorAfterFirst=(int)se114Rows($db,"SELECT last_id FROM $jt WHERE job=?",['delivery_region'])[0]['last_id'];
    $nullsAfterFirst=se114Count($db,"SELECT COUNT(*) AS total FROM $pt WHERE delivery_region IS NULL");
    // Controlled pause between committed batches; the process is not killed.
    $resumed=[]; $updatedAfterResume=0;
    do{$batch=se114BackfillBatch($db,$products,$progress,2); $resumed[]=$batch; $updatedAfterResume+=$batch['updated'];}while($batch['ids']);
    $replay=se114BackfillBatch($db,$products,$progress,2);
    $final=se114Rows($db,"SELECT id,delivery_region FROM $pt ORDER BY id");
    $rows=[['После первой порции',(string)$cursorAfterFirst,'Осталось NULL: '.$nullsAfterFirst],['Продолжение с checkpoint','104','Изменено строк: '.$updatedAfterResume],['Повтор после завершения',(string)$replay['last_id'],'Изменено строк: '.$replay['updated']]];
    foreach($final as $row){$rows[]=['Товар',(string)$row['id'],$row['delivery_region']];}
    $checks=['first_batch_committed'=>$first['ids']===[101,104] && $cursorAfterFirst===104 && $nullsAfterFirst===3,'resume_starts_after_checkpoint'=>$resumed[0]['ids']===[108,115],'only_missing_values_filled'=>$updatedAfterResume===3 && $final[4]['delivery_region']==='Особый','all_six_rows_present'=>count($final)===6,'no_nulls_left'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt WHERE delivery_region IS NULL")===0,'replay_changes_nothing'=>$replay===['ids'=>[],'last_id'=>140,'updated'=>0]];
    $details=['first'=>$first,'resume_batches'=>$resumed,'repeat'=>$replay,'checkpoint_after_first'=>$cursorAfterFirst];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-resumable-backfill', ['Шаг', 'ID / курсор', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-resumable-backfill: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-resumable-backfill: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Поставьте размер порции 3. После первой фиксации checkpoint должен стать 108, а готовое значение ID 120 по-прежнему останется «Особый». Предлагаемое решение не меняет условие id > last_id: изменяется только аргумент batchSize.

Найти NULL, увидеть отказ ALTER и только затем сделать поле обязательным

После перехода на новый формат приложение рассчитывает, что зона есть у каждого товара. Нельзя просто объявить NOT NULL и считать старые данные корректными: сначала найдём незаполненные строки, увидим отказ преждевременного изменения и исправим причину.

Исходные данные

У четырёх товаров зона уже заполнена. Пропуски остались у ID 104, city=Казань, и ID 115, city=Самара. Правило доставки для этих двух городов подтверждено: «Регион». Учебное соединение работает в строгом SQL mode; программа это проверяет, но не меняет настройку.

Путь изменения и проверки
  1. SELECT ... IS NULL → 104 и 115
  2. Преждевременный ALTER NOT NULL → отказ
  3. Подтверждённое заполнение двух строк → NULL осталось 0
  4. Повторный ALTER → IS_NULLABLE=NO
  5. UPDATE с NULL → ошибка 1048

Что получится

ШагРезультат
Перед миграциейNULL у ID 104, 115
Первая попытка ALTERОтклонена: в данных есть NULL
После исправленияNULL: 0
Схема после повторного ALTERIS_NULLABLE=NO
Новая попытка записать NULL1048: запрещена

Главная запись SQL

SELECT id,city FROM products
WHERE delivery_region IS NULL ORDER BY id;

-- После подтверждения правила доставки для этих городов:
UPDATE products SET delivery_region='Регион'
WHERE delivery_region IS NULL AND city IN ('Казань','Самара');

-- Выполняем, только когда COUNT(*) с IS NULL равен 0.
ALTER TABLE products
    MODIFY COLUMN delivery_region VARCHAR(16) NOT NULL,
    ALGORITHM=INPLACE;

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

IS NULL проверяет отсутствие значения; запись = NULL для этой задачи не подходит. MODIFY COLUMN меняет определение существующего столбца. NOT NULL запрещает отсутствующие значения, но не запрещает пустую строку, пробелы или неверную зону: для них нужны отдельные правила.

Самостоятельная программа намеренно выполняет ALTER до исправления данных. Ожидаемый отказ перехватывается как PDOException; не относящаяся к нему ошибка будет передана дальше. После отказа проверяется, что столбец остался nullable. Затем UPDATE заполняет только согласованные пропуски, COUNT подтверждает ноль NULL, повторный ALTER меняет структуру. Последняя попытка записать NULL показывает, что новое правило действительно работает.

В MODIFY заново указывают тип и все атрибуты, которые должны остаться: например, UNSIGNED, DEFAULT, COMMENT и COLLATE, если они были частью определения. Они не переносятся автоматически из прежней записи. В этом примере сохраняется VARCHAR(16), а прежний DEFAULT NULL намеренно убирается вместе с переходом к обязательному значению. Индексы не нужно повторять как атрибут этого столбца.

ALGORITHM=INPLACE задаёт требуемый способ ALTER; при невозможности сервер возвращает ошибку вместо молчаливой замены алгоритма. INPLACE не означает отсутствие перестройки: изменение nullable в InnoDB может её выполнять. Пример показывает правильность перехода, а оценка стоимости требует отдельной работы с размером и нагрузкой.

Границы и ошибки

Один SELECT перед ALTER не блокирует все будущие записи. В действующей системе старые писатели NULL должны быть обновлены до ужесточения схемы; конкурентная запись может заставить ALTER снова отказать, и это полезный сигнал. SQL mode влияет на преобразования, поэтому здесь строгий режим — проверяемая предпосылка. Нельзя заменять неизвестный смысл пустой строкой лишь ради прохождения NOT NULL. Обработка implicit COMMIT отдельно разобрана в главе о транзакциях PDO.

Полная самостоятельная программа и файлы
Найти NULL, увидеть отказ ALTER и только затем сделать поле обязательнымСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
$db->exec("ALTER TABLE $pt ADD COLUMN delivery_region VARCHAR(16) NULL DEFAULT NULL");
    $db->exec("UPDATE $pt SET delivery_region=CASE WHEN city='Москва' THEN 'Столица' ELSE 'Регион' END WHERE id NOT IN (104,115)");
    $missing=se114Rows($db,"SELECT id,city FROM $pt WHERE delivery_region IS NULL ORDER BY id");
    $failedCode=null;
    try{$db->exec("ALTER TABLE $pt MODIFY COLUMN delivery_region VARCHAR(16) NOT NULL, ALGORITHM=INPLACE");}
    catch(PDOException $error){$code=(int)($error->errorInfo[1]??0);if(!in_array($code,[1138,1265],true)){throw $error;}$failedCode=$code;}
    $stillNullable=se114Column($db,$products,'delivery_region')['IS_NULLABLE']==='YES';
    $db->exec("UPDATE $pt SET delivery_region='Регион' WHERE delivery_region IS NULL AND city IN ('Казань','Самара')");
    $remainingNulls=se114Count($db,"SELECT COUNT(*) AS total FROM $pt WHERE delivery_region IS NULL");
    if($remainingNulls!==0){throw new RuntimeException('Resolve every missing region before NOT NULL');}
    $db->exec("ALTER TABLE $pt MODIFY COLUMN delivery_region VARCHAR(16) NOT NULL, ALGORITHM=INPLACE");
    $newNullCode=null;
    try{$db->prepare("UPDATE $pt SET delivery_region=? WHERE id=?")->execute([null,101]);}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1048){throw $error;}$newNullCode=1048;}
    $rows=[['Перед миграцией','NULL у ID '.implode(', ',array_column($missing,'id'))],['Первая попытка ALTER',$failedCode!==null?'Отклонена: в данных есть NULL':'НЕОЖИДАННО ПРИНЯТА'],['После исправления','NULL: '.$remainingNulls],['Схема после повторного ALTER','IS_NULLABLE='.se114Column($db,$products,'delivery_region')['IS_NULLABLE']],['Новая попытка записать NULL',(string)$newNullCode.': запрещена']];
    $checks=['two_missing_values_found'=>array_map('intval',array_column($missing,'id'))===[104,115],'alter_rejected_and_schema_preserved'=>$failedCode!==null && $stillNullable,'all_missing_values_resolved'=>$remainingNulls===0,'not_null_is_installed'=>se114Column($db,$products,'delivery_region')['IS_NULLABLE']==='NO','new_null_rejected'=>$newNullCode===1048,'six_rows_preserved'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt")===6];
    $details=['failed_alter_driver_code'=>$failedCode,'missing_before'=>$missing,'null_update_driver_code'=>$newNullCode];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-require-value', ['Шаг', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-require-value: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-require-value: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Оставьте один неизвестный город с NULL и не добавляйте его в подтверждённый список UPDATE. Ожидайте остановку до второго ALTER. Предлагаемое исправление — сначала определить зону для этого города, затем повторить проверку; не ослаблять правило и не удалять товар.

Практический вопрос: Почему NOT NULL не устанавливается при старых NULL. Автор меняет nullable TIMESTAMP и получает 1138. В нашем самостоятельном сценарии сначала обнаруживаются незаполненные зоны, затем проверяется отказ ALTER, осмысленное исправление и новый запрет NULL. Обсуждение задаёт проблему; правила сверены по официальной документации, решение и данные здесь самостоятельные.

Разобрать конфликт артикулов перед добавлением UNIQUE

Из-за ошибки импорта два разных товара получили артикул MUG-A. Магазин хочет сделать артикул уникальным, чтобы следующий импорт не повторил ошибку. Индекс не должен случайно выбрать, какой товар оставить: конфликт сначала разбирается как ошибка данных.

Исходные данные

IDТоварОшибочный skuПодтверждённый sku
101Кружка белаяMUG-AMUG-A
104Кружка синяяMUG-AMUG-B

Остальные четыре артикула различаются. Исходный складской документ подтверждает MUG-B у синей кружки. Мы исправляем именно ID 104, сохраняем оба товара и не придумываем новое бизнес-значение.

Путь изменения и проверки
  1. GROUP BY sku HAVING COUNT(*) > 1 → MUG-A, две строки
  2. ADD UNIQUE на грязных данных → ошибка 1062
  3. Проверенный UPDATE для 104 → MUG-B
  4. Повторный ADD UNIQUE успешен
  5. INSERT ещё одного MUG-A → 1062; шесть товаров сохранены

Что получится

ШагРезультат
Конфликт до измененияMUG-A: 101, 104
Первый ADD UNIQUE1062: индекс не добавлен
Проверенное исправление104 → MUG-B
Дубликатов после исправления0
Новая попытка повторить MUG-A1062: строка не добавлена
Товаров осталось6

Главная запись SQL

SELECT sku,COUNT(*) AS occurrences FROM products
GROUP BY sku HAVING COUNT(*)>1;

SELECT id,title,sku FROM products WHERE sku='MUG-A' ORDER BY id;

-- Исходный складской документ подтверждает этот артикул для ID 104.
UPDATE products SET sku='MUG-B' WHERE id=104 AND sku='MUG-A';

ALTER TABLE products ADD UNIQUE KEY sku_unique (sku);

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

GROUP BY sku собирает одинаковые артикулы в группы; COUNT(*) считает строки каждой группы; HAVING COUNT(*)>1 оставляет конфликты. Отдельный SELECT показывает конкретные товары, чтобы понять причину. Сравнение строк выполняется по collation столбца: здесь utf8mb4_0900_ai_ci, поэтому проверка и будущий индекс используют одну семантику.

ADD UNIQUE KEY sku_unique (sku) создаёт индекс и одновременно правило уникальности. Преждевременный вызов возвращает 1062. Программа проверяет INFORMATION_SCHEMA.STATISTICS: неудачный индекс не появился. После согласованного исправления групп с повторами не остаётся, индекс добавляется, и новый дубль отвергается уже при INSERT.

Автоматически удалить все строки кроме MIN(id) опасно: у товаров могут быть остатки, изображения и строки заказов. Если записи действительно представляют один объект, нужен отдельный план объединения связанных данных. Здесь объекты разные, поэтому правильное действие — восстановить исходный артикул. Правила сравнения текста объясняют регистр и акценты, а проектирование индексов — применение индекса для запросов.

Границы и ошибки

UNIQUE допускает несколько NULL в nullable-столбце; sku здесь сразу NOT NULL. Для мультимагазина правило может быть UNIQUE(shop_id, sku), а не глобальный sku. Нужное правило определяет модель данных. ADD UNIQUE не очищает конфликты и не заменяет резервную копию. Время построения индекса и блокировки на большой таблице не измеряются этим маленьким примером.

Полная самостоятельная программа и файлы
Разобрать конфликт артикулов перед добавлением UNIQUEСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
$db->prepare("UPDATE $pt SET sku=? WHERE id=?")->execute(['MUG-A',104]);
    $duplicates=se114Rows($db,"SELECT sku,COUNT(*) AS occurrences FROM $pt GROUP BY sku HAVING COUNT(*)>1 ORDER BY sku");
    $conflictRows=se114Rows($db,"SELECT id,title,sku FROM $pt WHERE sku=? ORDER BY id",['MUG-A']);
    $failedCode=null;
    try{$db->exec("ALTER TABLE $pt ADD UNIQUE KEY sku_unique (sku)");}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1062){throw $error;}$failedCode=1062;}
    $indexAfterFailure=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND INDEX_NAME=?',[$products,'sku_unique']);
    // The warehouse source confirms MUG-B for the blue mug, ID 104.
    $db->prepare("UPDATE $pt SET sku=? WHERE id=? AND sku=?")->execute(['MUG-B',104,'MUG-A']);
    $duplicatesAfter=se114Rows($db,"SELECT sku,COUNT(*) AS occurrences FROM $pt GROUP BY sku HAVING COUNT(*)>1");
    if($duplicatesAfter){throw new RuntimeException('Duplicate article codes remain');}
    $db->exec("ALTER TABLE $pt ADD UNIQUE KEY sku_unique (sku)");
    $insertCode=null;
    try{$db->prepare("INSERT INTO $pt (id,title,sku,city,category_id) VALUES (?,?,?,?,?)")->execute([999,'Ещё одна кружка','MUG-A','Москва',10]);}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1062){throw $error;}$insertCode=1062;}
    $fixed=se114Rows($db,"SELECT id,sku FROM $pt WHERE id IN (101,104) ORDER BY id");
    $rows=[['Конфликт до изменения','MUG-A: '.implode(', ',array_column($conflictRows,'id'))],['Первый ADD UNIQUE',(string)$failedCode.': индекс не добавлен'],['Проверенное исправление','104 → '.$fixed[1]['sku']],['Дубликатов после исправления',(string)count($duplicatesAfter)],['Новая попытка повторить MUG-A',(string)$insertCode.': строка не добавлена'],['Товаров осталось',(string)se114Count($db,"SELECT COUNT(*) AS total FROM $pt")]];
    $checks=['duplicate_pair_detected'=>count($duplicates)===1 && (int)$duplicates[0]['occurrences']===2 && array_map('intval',array_column($conflictRows,'id'))===[101,104],'add_unique_rejected'=>$failedCode===1062 && $indexAfterFailure===0,'confirmed_code_restored'=>$fixed[1]['sku']==='MUG-B' && $fixed[0]['sku']==='MUG-A','unique_index_installed'=>se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND INDEX_NAME=? AND NON_UNIQUE=0',[$products,'sku_unique'])===1,'duplicate_insert_rejected'=>$insertCode===1062,'all_products_preserved'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt")===6];
    $details=['conflict_rows'=>$conflictRows,'failed_alter_driver_code'=>$failedCode,'duplicate_insert_driver_code'=>$insertCode,'unique_index_absent_after_failure'=>$indexAfterFailure===0];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-unique-after-cleanup', ['Шаг', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-unique-after-cleanup: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-unique-after-cleanup: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Добавьте вторую подтверждённую ошибку артикула у ID 115. Сначала выведите обе конфликтные группы, исправьте их по идентификаторам и только затем создавайте индекс. Предлагаемое решение сохраняет COUNT(*) товаров равным 6 и не использует IGNORE.

Практический вопрос: Удалит ли UNIQUE уже накопившиеся дубликаты. Вопрос о большой таблице, куда попали повторы из-за обычного индекса вместо UNIQUE. Наш пример сохраняет оба товара и исправляет подтверждённый артикул; устаревшие советы ALTER IGNORE не перенимаются. Обсуждение задаёт проблему; правила сверены по официальной документации, решение и данные здесь самостоятельные.

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

В старом импорте category_id был просто числом, поэтому в товарах сохранилась ссылка на несуществующую категорию 999. Хотим, чтобы база сама запрещала такие записи. Перед добавлением внешнего ключа нужно найти разорванную связь и решить, что она означает.

Исходные данные

Категории сейчасТовар с ошибкойПодтверждённое соответствие
10 Посуда; 20 Освещение104 Кружка синяя → 999Старая категория импорта 999 → 10 Посуда

Оба id имеют тип INT UNSIGNED. У categories.id есть PRIMARY KEY. category_id разрешает NULL для пока не назначенной категории, но значение 999 не является NULL и требует существующей строки-родителя.

Путь изменения и проверки
  1. LEFT JOIN сохраняет товар 104, даже когда категория не найдена
  2. c.id IS NULL → обнаружена ссылка 104 → 999
  3. ADD FOREIGN KEY → 1452, данные сохранены
  4. Согласованное исправление 104 → 10 → повторный ALTER
  5. Новая ссылка на 999 отвергается

Что получится

ШагРезультат
Потерянная связь104 → 999
Первый ADD FOREIGN KEY1452: связь не добавлена
Исправленный товар104 Кружка синяя → Посуда
Потерянных связей после исправления0
Новая попытка указать 9991452: запрещена
Товаров осталось6

Главная запись SQL

SELECT p.id,p.title,p.category_id
FROM products AS p LEFT JOIN categories AS c ON c.id=p.category_id
WHERE p.category_id IS NOT NULL AND c.id IS NULL;

-- Подтверждённая карта импорта: старая категория 999 соответствует 10.
UPDATE products SET category_id=10 WHERE id=104 AND category_id=999;

ALTER TABLE products
    ADD FOREIGN KEY(category_id) REFERENCES categories(id)
    ON DELETE RESTRICT ON UPDATE RESTRICT;

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

LEFT JOIN оставляет все товары в левой таблице. Когда соответствующей категории нет, поля правой стороны становятся NULL. Условие p.category_id IS NOT NULL AND c.id IS NULL отличает потерянную числовую ссылку от разрешённого отсутствия категории.

FOREIGN KEY(category_id) REFERENCES categories(id) связывает дочерний столбец с ключом родителя. Таблицы InnoDB, типы и знаковость id совпадают. Первая попытка добавить правило заканчивается 1452; программа проверяет, что FK не появился. UPDATE по подтверждённой карте исправляет один товар, повторный поиск не находит потерянных ссылок, ALTER успешно устанавливает связь.

ON DELETE RESTRICT не разрешит удалить категорию, пока на неё ссылаются товары. ON UPDATE RESTRICT запрещает аналогичную смену её ключа. Это выбранная политика каталога: операцию нужно решить явно. В примере не включается CASCADE и не отключаются проверки FK. Первичные и внешние ключи и иерархия категорий показывают другие применения связей.

Границы и ошибки

При отсутствии достоверного соответствия 999 → 10 переназначение было бы порчей данных. Альтернатива зависит от задачи: восстановить реальную категорию, отправить запись на ручной разбор или разрешённо оставить NULL. Не удаляйте товар автоматически ради установки FK. Выключение FOREIGN_KEY_CHECKS не исправляет строки; обратное включение не выполняет полную повторную проверку накопленных данных. В этой программе настройка вообще не меняется.

Полная самостоятельная программа и файлы
Найти потерянную категорию и добавить проверяемую связьСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
se114Create($db,$categories,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL',$created);
    $ct=se114Name($categories);
    $db->prepare("INSERT INTO $ct (id,title) VALUES (?,?),(?,?)")->execute([10,'Посуда',20,'Освещение']);
    $db->prepare("UPDATE $pt SET category_id=? WHERE id=?")->execute([999,104]);
    $orphans=se114Rows($db,"SELECT p.id,p.title,p.category_id FROM $pt AS p LEFT JOIN $ct AS c ON c.id=p.category_id WHERE p.category_id IS NOT NULL AND c.id IS NULL ORDER BY p.id");
    $failedCode=null;
    try{$db->exec("ALTER TABLE $pt ADD FOREIGN KEY(category_id) REFERENCES $ct(id) ON DELETE RESTRICT ON UPDATE RESTRICT");}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1452){throw $error;}$failedCode=1452;}
    $fkAfterFailure=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=? AND REFERENCED_TABLE_NAME IS NOT NULL',[$products,'category_id']);
    // Confirmed mapping from this import: obsolete category 999 means category 10.
    $db->prepare("UPDATE $pt SET category_id=? WHERE id=? AND category_id=?")->execute([10,104,999]);
    $orphansAfter=se114Count($db,"SELECT COUNT(*) AS total FROM $pt AS p LEFT JOIN $ct AS c ON c.id=p.category_id WHERE p.category_id IS NOT NULL AND c.id IS NULL");
    if($orphansAfter!==0){throw new RuntimeException('Resolve orphaned category references first');}
    $db->exec("ALTER TABLE $pt ADD FOREIGN KEY(category_id) REFERENCES $ct(id) ON DELETE RESTRICT ON UPDATE RESTRICT");
    $newOrphanCode=null;
    try{$db->prepare("UPDATE $pt SET category_id=? WHERE id=?")->execute([999,101]);}
    catch(PDOException $error){if((int)($error->errorInfo[1]??0)!==1452){throw $error;}$newOrphanCode=1452;}
    $fixed=se114Rows($db,"SELECT p.id,p.title,c.title AS category FROM $pt AS p JOIN $ct AS c ON c.id=p.category_id WHERE p.id=?",[104])[0];
    $rows=[['Потерянная связь','104 → '.$orphans[0]['category_id']],['Первый ADD FOREIGN KEY',(string)$failedCode.': связь не добавлена'],['Исправленный товар',$fixed['id'].' '.$fixed['title'].' → '.$fixed['category']],['Потерянных связей после исправления',(string)$orphansAfter],['Новая попытка указать 999',(string)$newOrphanCode.': запрещена'],['Товаров осталось',(string)se114Count($db,"SELECT COUNT(*) AS total FROM $pt")]];
    $checks=['orphan_identified'=>count($orphans)===1 && (int)$orphans[0]['id']===104 && (int)$orphans[0]['category_id']===999,'fk_add_rejected'=>$failedCode===1452 && $fkAfterFailure===0,'orphan_resolved_without_deletion'=>$orphansAfter===0 && $fixed['category']==='Посуда','fk_installed'=>se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=? AND REFERENCED_TABLE_NAME=?',[$products,'category_id',$categories])===1,'new_orphan_rejected'=>$newOrphanCode===1452,'six_products_preserved'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt")===6];
    $details=['orphan_rows'=>$orphans,'failed_alter_driver_code'=>$failedCode,'bad_update_driver_code'=>$newOrphanCode,'foreign_key_absent_after_failure'=>$fkAfterFailure===0];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-foreign-key-after-cleanup', ['Шаг', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-foreign-key-after-cleanup: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-foreign-key-after-cleanup: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Поставьте category_id=NULL у одного обычного товара и оставьте потерянную ссылку 999 у другого. Поиск должен показать только второго. Предлагаемое решение сохраняет условие p.category_id IS NOT NULL; после согласованного исправления FK допускает NULL, но отвергает 999.

Практический вопрос: Что произойдёт с потерянными ссылками при добавлении FK. Автор спрашивает, исчезнут ли дочерние строки без родителей. Наш пример показывает отказ установки FK, поиск LEFT JOIN и исправление по подтверждённой карте без удаления товара или выключения проверок. Обсуждение задаёт проблему; правила сверены по официальной документации, решение и данные здесь самостоятельные.

Продолжить миграцию, когда столбец уже создан, а запись в журнале отсутствует

Скрипт успел добавить столбец, но до записи «миграция выполнена» не дошёл. При повторе нельзя безусловно выполнить ADD COLUMN ещё раз и нельзя объявить успех только по совпадению имени. Нужно сверить фактическую схему с ожидаемой и восстановить недостающую отметку.

Исходные данные

Свежая products без delivery_region и пустая migrations. Столбцы журнала: version — уникальный идентификатор шага, fingerprint — SHA-256 согласованного описания этого шага. Желаемое определение: VARCHAR(16), NULL, DEFAULT NULL, utf8mb4, utf8mb4_0900_ai_ci, без вычисления или специальных EXTRA-атрибутов.

Путь изменения и проверки
  1. Журнал пуст, столбца нет → один ADD COLUMN
  2. Контролируемый return до INSERT журнала
  3. Повтор: столбец совпал по определению → записываем журнал
  4. Следующий повтор: журнал и схема совпали → ничего не меняем
  5. Отдельно меняем длину на 24 → сверка отказывает

Что получится

ШагСостояниеЧто наблюдаем
Управляемая остановкаcolumn_ready_journal_pendingСтолбец: да; журнал: 0
Продолжениеjournal_recordedADD COLUMN выполнен: 1 раз
Повтор после завершенияalready_recordedЗаписей журнала: 1
Проверка чужого измененияvarchar(24) вместо varchar(16)Остановлено: схема не совпадает

Главная запись SQL

SELECT fingerprint FROM migrations WHERE version=?;

SELECT COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT,
       CHARACTER_SET_NAME,COLLATION_NAME,EXTRA,GENERATION_EXPRESSION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME='delivery_region';

-- Только если столбца нет и нет отметки о выполнении:
ALTER TABLE products ADD COLUMN delivery_region VARCHAR(16) NULL DEFAULT NULL;

-- Только после проверки, что фактическая схема точно соответствует ожидаемой:
INSERT INTO migrations (version,fingerprint) VALUES (?,?);

Фрагмент использует описанные выше таблицы и показывает центральные команды. В самостоятельной программе таблицы создаются с уникальными именами; значения передаются параметрами, а имена проверяются отдельно. Предыдущие архивы запускать не требуется.

Идемпотентный повтор означает, что уже выполненный шаг можно повторно вызвать без второго такого же изменения. Это свойство конкретной логики, а не любого ALTER. se114Migrate() сначала читает отметку и описание столбца, при необходимости выполняет ADD, повторно сверяет определение и только после этого записывает журнал.

INFORMATION_SCHEMA.COLUMNS сообщает структуру. Фильтр содержит текущую схему, точное имя таблицы и столбца: одно имя без схемы могло бы совпасть с чужой таблицей. COLUMN_TYPE показывает тип с длиной, IS_NULLABLE — YES/NO, COLUMN_DEFAULT — значение по умолчанию. Отдельно проверяются кодировка, collation и отсутствие вычисляемого выражения. Для nullable-поля отсутствие явного DEFAULT и DEFAULT NULL отражаются одинаковым NULL; требуемое поведение здесь совпадает.

Первый вызов специально возвращает управление после успешного DDL, оставляя журнал пустым. Это управляемая остановка шага внутри живого процесса. Второй вызов обнаруживает уже правильный столбец, не повторяет ALTER и добавляет одну отметку. Третий ничего не меняет. Дополнительная проверка меняет длину на 24 и вызывает тот же шаг: он останавливается, хотя имя столбца и запись журнала существуют.

Атомарность одного DDL в InnoDB не объединяет ALTER и последующий INSERT журнала в одну пользовательскую транзакцию. DDL вызывает implicit COMMIT; между командами остаётся промежуточное состояние. Мы восстанавливаемся по наблюдаемой схеме, а не обещаем ROLLBACK уже созданного столбца. Границы DDL и транзакций PDO разбирает сам механизм фиксации; здесь он определяет порядок повторного запуска.

Границы и ошибки

Это учебный однопроцессный протокол одного конкретного шага, не готовый движок миграций. Несколько одновременных исполнителей требуют координации и блокировок; журнал с PRIMARY KEY сам по себе не устраняет гонку между проверкой и ALTER. Контрольная сумма отмечает версию определения, но не является защитой от злоумышленника. Исправление несовпавшей схемы требует отдельного решения; пример не приводит её к нужному виду автоматически. Авария процесса или сервера в этом сценарии не воспроизводится.

Полная самостоятельная программа и файлы
Продолжить миграцию, когда столбец уже создан, а запись в журнале отсутствуетСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/se114-helper.php';
$db=se114Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$settings=se114Rows($db,'SELECT @@SESSION.sql_mode AS sql_mode,@@SESSION.foreign_key_checks AS fk_checks')[0];
$modes=explode(',', $settings['sql_mode']);
if(!array_intersect($modes,['STRICT_TRANS_TABLES','STRICT_ALL_TABLES']) || (int)$settings['fk_checks']!==1){throw new RuntimeException('Use strict SQL mode and enabled foreign-key checks in the teaching database');}
$prefix='mysql114_schema_'.bin2hex(random_bytes(8));
$products=$prefix.'_products'; $categories=$prefix.'_categories'; $progress=$prefix.'_progress'; $journal=$prefix.'_migrations';
$created=[]; $details=[];
try {
    se114Create($db,$products,'id INT UNSIGNED NOT NULL PRIMARY KEY,title VARCHAR(80) NOT NULL,sku VARCHAR(32) NOT NULL,city VARCHAR(40) NOT NULL,category_id INT UNSIGNED NULL',$created);
    se114Seed($db,$products); $pt=se114Name($products);
se114Create($db,$journal,'version VARCHAR(80) NOT NULL PRIMARY KEY,fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL',$created);
    $jt=se114Name($journal); $ddlRuns=0;
    // Controlled return after DDL and before the journal INSERT, not a process crash.
    $first=se114Migrate($db,$products,$journal,true,$ddlRuns);
    $journalAfterFirst=se114Count($db,"SELECT COUNT(*) AS total FROM $jt");
    $columnAfterFirst=se114Column($db,$products,'delivery_region')!==null;
    $recovered=se114Migrate($db,$products,$journal,false,$ddlRuns);
    $repeat=se114Migrate($db,$products,$journal,false,$ddlRuns);
    $journalAfterRecovery=se114Count($db,"SELECT COUNT(*) AS total FROM $jt");
    $beforeDrift=se114Column($db,$products,'delivery_region');
    // Separate controlled schema drift: the same name alone is not enough.
    $db->exec("ALTER TABLE $pt MODIFY COLUMN delivery_region VARCHAR(24) NULL DEFAULT NULL");
    $driftRejected=false;
    try{se114Migrate($db,$products,$journal,false,$ddlRuns);}
    catch(UnexpectedValueException $error){$driftRejected=true;}
    $rows=[['Управляемая остановка',$first,'Столбец: да; журнал: '.$journalAfterFirst],['Продолжение',$recovered,'ADD COLUMN выполнен: '.$ddlRuns.' раз'],['Повтор после завершения',$repeat,'Записей журнала: '.$journalAfterRecovery],['Проверка чужого изменения','varchar(24) вместо varchar(16)',$driftRejected?'Остановлено: схема не совпадает':'НЕОЖИДАННО ПРИНЯТО']];
    $checks=['ddl_applied_before_journal'=>$first==='column_ready_journal_pending' && $journalAfterFirst===0 && $columnAfterFirst,'recovery_without_second_ddl'=>$recovered==='journal_recorded' && $ddlRuns===1,'replay_idempotent'=>$repeat==='already_recorded' && $journalAfterRecovery===1,'full_column_contract_checked'=>$beforeDrift['COLUMN_TYPE']==='varchar(16)' && $beforeDrift['IS_NULLABLE']==='YES' && $beforeDrift['COLUMN_DEFAULT']===null,'schema_drift_rejected'=>$driftRejected && se114Column($db,$products,'delivery_region')['COLUMN_TYPE']==='varchar(24)','journal_not_duplicated'=>se114Count($db,"SELECT COUNT(*) AS total FROM $jt")===1,'six_rows_preserved'=>se114Count($db,"SELECT COUNT(*) AS total FROM $pt")===6];
    $details=['controlled_stop_not_process_crash'=>true,'ddl_add_column_count'=>$ddlRuns,'journal_after_controlled_stop'=>$journalAfterFirst,'journal_after_recovery'=>$journalAfterRecovery,'column_before_drift'=>$beforeDrift,'drift_rejected'=>$driftRejected];
} finally {
    if($db->inTransaction()){$db->rollBack();}
    foreach([$products,$categories,$progress,$journal] as $name){
        if($created[$name]??false){$db->exec('DROP TABLE '.se114Name($name));}
    }
}
$remaining=se114Count($db,'SELECT COUNT(*) AS total FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (?,?,?,?)',[$products,$categories,$progress,$journal]);
$checks['owned_tables_removed']=$remaining===0;
$db=null;
se114Report('mysql114-schema-journal-recovery', ['Шаг', 'Состояние', 'Что наблюдаем'], $rows, $checks, $runtime, $details);
echo 'mysql114-schema-journal-recovery: saved result.html and result.json
';
Ожидаемый результат
mysql114-schema-journal-recovery: saved result.html and result.json

После php example.php откройте result.html и сопоставьте строки с таблицей выше. Данные, helper, README и проверка собственных таблиц входят в самостоятельный пример.

Открыть фактический HTML-результат · Скачать результат JSON

Попробуйте сами. Сначала запустите se114Migrate с stopBeforeJournal=false на свежих таблицах: ожидайте одну отметку и один ADD. Затем измените ожидаемую версию определения, сохранив прежний version: правильная реакция — отказ из-за несовпадения fingerprint, а не молчаливая перезапись журнала.

Соединить миграцию с записью данных и запросами сайта

Правило в базе защищает будущие записи, но не заменяет подготовку старых данных. Сначала найдите конкретные строки, согласуйте исправление, проверьте остаток проблемы и только затем меняйте ограничение. При повторе миграции сравнивайте реальную схему с нужным состоянием; запись журнала и имя столбца по отдельности недостаточны.

Для выбора индексов под конкретный экран перейдите к плану запроса и устройству индекса. Для переноса входных событий — к повторяемому импорту, для сохранения смысла типов — к контрактам данных. Эти разделы дополняют миграции: изменение структуры не должно подменять правила товара, заказа или интеграции.

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

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

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

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

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