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

Дерево руководства
Глава 114 · После таблиц, JOIN и первых подготовленных SELECT
MySQL: связанные данные без дублей, обрезанных заказов и неверных сумм
EXISTS и NULL, страница родителей, два запроса вместо N + 1, нулевые счётчики и независимые суммы позиций/платежей.
Какую строку должен получить экран
Вы уже умеете связать таблицы, но экрану нужна определённая форма: одна карточка покупателя, две карточки заказа со всеми позициями или две независимые суммы. SQL может успешно выполниться и вернуть не ту форму. Здесь сверяем запрос с тем, что должен увидеть человек.
- Покупатель: нужен ли хотя бы один оплаченный заказ?
- Страница: два заказа, у каждого все позиции
- Счётчик: реальные позиции, включая ноль
- Две суммы: отдельно позиции и отдельно платежи
| Термин | Что означает в этой главе |
|---|---|
| Родитель | Покупатель или заказ, чья карточка нужна экрану |
| Дочерние строки | Заказы покупателя либо позиции/платежи заказа |
| Один ко многим, 1:N | Один родитель связан с несколькими строками справа |
| Строка JOIN | Одна комбинация совпавших записей; не обязательно одна карточка |
| Агрегация | Расчёт одного итога по группе исходных строк |
Сначала определите, одна строка результата означает покупателя, заказ, позицию или сводку. Затем выбирайте SQL. Полная схема с настоящими ключами и связями разобрана в устройстве таблиц, а явные поля и ограничение выдачи — в SQL для нужного экрана. Нельзя исправить смысл результата настройкой буфера PDO.
Запустить один самостоятельный пример
Полные архивы рассчитаны на PHP 8.4+ с PDO_MYSQL/mysqlnd и MySQL 8.4; контрольное окружение — PHP 8.5.11 / MySQL 8.4.13. Используются PDO::connect и современные константы драйвера. Это требования комплекта, не дата появления EXISTS или JOIN.
Для своего запуска подготовьте локальный сервер, отдельную базу и ограниченного пользователя по инструкции настройки. Задайте в терминале PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER и PHPMAN_MYSQL_PASSWORD. На контрольном стенде 127.0.0.1:33070; у вас может быть другой порт. Распакуйте один ZIP и выполните php example.php; откройте result.html. README и rs110-helper.php уже внутри, предыдущие примеры выполнять не нужно.
Каждая программа сама создаёт только TEMPORARY-таблицы mysql110_rs_* и свои фиксированные данные. При закрытии соединения они исчезают. Постоянные таблицы не меняются; отдельная установка схемы не нужна. Эти временные таблицы моделируют связи без FOREIGN KEY: ограничения InnoDB проверены в отдельной базовой главе. result.json содержит те же строки, metrics.json — версии/проверки, без пароля.
Выбрать покупателей с оплатой и не повторять карточки
Менеджеру нужен список покупателей, у которых была хотя бы одна оплата. Анна сделала два оплаченных заказа, но её карточка должна появиться один раз. Сведения о каждом заказе этому экрану не нужны.
Исходные данные
| Покупатель | Его заказы | Что означает |
|---|---|---|
| 1 — Анна | 101 paid; 102 paid | Две оплаты, одна карточка покупателя |
| 2 — Борис | 103 draft | Заказ есть, оплаты нет |
| 3 — Вера | Нет заказов | Тоже нет оплаты |
| Без покупателя | 104 paid, customer_id=NULL | Гостевая запись не относится ни к одному из трёх |
- Анна → оплаченные заказы 101 и 102
- EXISTS проверяет наличие подходящей строки
- Во внешний SELECT попадает сама Анна один раз
- В PHP приходят только id и name покупателя
Что получится
| Выборка | Покупатель ID | Имя | Заказ ID |
|---|---|---|---|
| JOIN: строка заказа | 1 | Анна | 101 |
| JOIN: строка заказа | 1 | Анна | 102 |
| EXISTS: карточка покупателя | 1 | Анна | NULL |
SQL, который выражает задачу
SELECT c.id, c.name
FROM mysql110_rs_customers AS c
WHERE EXISTS (
SELECT 1 FROM mysql110_rs_orders AS o
WHERE o.customer_id = c.id AND o.status = 'paid'
)
ORDER BY c.id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
Внешний SELECT выбирает покупателей. AS c задаёт короткое имя таблице; c.id — поле покупателя. В скобках записан второй SELECT, то есть подзапрос. Условие o.customer_id = c.id связывает проверку с текущим покупателем, а status = 'paid' задаёт нужный тип заказа. EXISTS отвечает на вопрос «нашлась хотя бы одна такая строка?». Число подходящих заказов не увеличивает число карточек.
В сравнении обычный JOIN честно возвращает две пары покупатель–заказ для Анны. Это правильная форма для списка заказов, но неудобная для списка покупателей. DISTINCT по нужным полям тоже может убрать дубли; EXISTS непосредственно выражает проверку наличия. Внутри EXISTS список SELECT игнорируется MySQL: SELECT 1 — удобная запись намерения, а не обещание скорости относительно SELECT *. Столбцы подзапроса не пересылаются в PHP.
Границы и частые ошибки
EXISTS не выбирает «последний» заказ и не возвращает его поля. Если экрану нужна дата или сумма конкретного заказа, правило выбора надо задать отдельно. Индекс (customer_id,status) поддерживает поиск связанной оплаты, но пример не измеряет ускорение и не обещает единственный план оптимизатора. Базовая таблица покупателей должна сама содержать одну строку на ID.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
rs110Customers($db);
$joined=rs110Rows($db,"SELECT c.id,c.name,o.id AS order_id FROM mysql110_rs_customers AS c JOIN mysql110_rs_orders AS o ON o.customer_id=c.id WHERE o.status='paid' ORDER BY c.id,o.id");
$screen=rs110Rows($db,"SELECT c.id,c.name FROM mysql110_rs_customers AS c WHERE EXISTS (SELECT 1 FROM mysql110_rs_orders AS o WHERE o.customer_id=c.id AND o.status='paid') ORDER BY c.id");
$rows=[];foreach($joined as $r){$rows[]=['JOIN: строка заказа',(int)$r['id'],$r['name'],(int)$r['order_id']];}
foreach($screen as $r){$rows[]=['EXISTS: карточка покупателя',(int)$r['id'],$r['name'],null];}
$checks=['join_repeats_customer'=>array_map('intval',array_column($joined,'id'))===[1,1],'screen_has_one_anna'=>count($screen)===1 && (int)$screen[0]['id']===1 && $screen[0]['name']==='Анна'];
$details=['joined_rows'=>count($joined),'screen_rows'=>count($screen),'guest_order_not_linked'=>true];
$db=null;
rs110Report('mysql110-related-exists', ['Выборка', 'Покупатель ID', 'Имя', 'Заказ ID'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-exists: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-exists: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Замените paid на draft. До запуска предскажите результат: останется Борис. Затем придумайте экран, которому действительно нужны обе строки Анны, и используйте JOIN осознанно.
Найти покупателей без оплаты, даже если в заказах есть NULL
Для напоминания нужны покупатели без оплаченного заказа. Борис с черновиком и Вера без заказов подходят. Гостевой оплаченный заказ с NULL не должен заставить весь список исчезнуть.
Исходные данные
| Покупатель | Его заказы | Что означает |
|---|---|---|
| 1 — Анна | 101 paid; 102 paid | Две оплаты, одна карточка покупателя |
| 2 — Борис | 103 draft | Заказ есть, оплаты нет |
| 3 — Вера | Нет заказов | Тоже нет оплаты |
| Без покупателя | 104 paid, customer_id=NULL | Гостевая запись не относится ни к одному из трёх |
- Оплаченные customer_id: 1, 1, NULL
- NOT IN с NULL не подтверждает отсутствие ID 2 или 3
- NOT EXISTS ищет оплату именно текущего покупателя
- Результат: Борис и Вера
Что получится
| Способ | ID / число строк для пустого ответа | Результат |
|---|---|---|
| NOT IN с NULL | 0 | Нет строк |
| NOT EXISTS | 2 | Борис |
| NOT EXISTS | 3 | Вера |
| NOT IN после IS NOT NULL | 2 | Борис |
| NOT IN после IS NOT NULL | 3 | Вера |
SQL, который выражает задачу
SELECT c.id, c.name
FROM mysql110_rs_customers AS c
WHERE NOT EXISTS (
SELECT 1 FROM mysql110_rs_orders AS o
WHERE o.customer_id = c.id AND o.status = 'paid'
)
ORDER BY c.id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
NOT EXISTS оставляет покупателя, только если связанный оплаченный заказ не найден. Для Бориса подзапрос отбрасывает draft; для Веры совпадений вообще нет. NULL в гостевом заказе не равен ни одному известному ID, поэтому не делает этот заказ принадлежащим всем покупателям.
Выражение 2 NOT IN (1,NULL) не даёт true: сравнение с неизвестным значением остаётся неизвестным. WHERE сохраняет только true, поэтому на этих данных неправильный NOT IN возвращает пустой ответ. Это не ошибка PDO и не отсутствие покупателей. Вторая рабочая альтернатива исключает NULL из подзапроса через IS NOT NULL; она совпадает с NOT EXISTS здесь, где внешний c.id — первичный ключ, не допускающий NULL.
Границы и частые ошибки
Если внешнее значение тоже допускает NULL, сначала определите смысл такого случая: фильтрация NULL внутри списка не делает два выражения эквивалентными для любого входа. Не подставляйте фиктивный ID через COALESCE без правила предметной области. В примере «нет оплаты» включает наличие только черновиков; это не синоним «нет никаких заказов».
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
rs110Customers($db);
$bad=rs110Rows($db,"SELECT c.id,c.name FROM mysql110_rs_customers AS c WHERE c.id NOT IN (SELECT o.customer_id FROM mysql110_rs_orders AS o WHERE o.status='paid') ORDER BY c.id");
$screen=rs110Rows($db,"SELECT c.id,c.name FROM mysql110_rs_customers AS c WHERE NOT EXISTS (SELECT 1 FROM mysql110_rs_orders AS o WHERE o.customer_id=c.id AND o.status='paid') ORDER BY c.id");
$filtered=rs110Rows($db,"SELECT c.id,c.name FROM mysql110_rs_customers AS c WHERE c.id NOT IN (SELECT o.customer_id FROM mysql110_rs_orders AS o WHERE o.status='paid' AND o.customer_id IS NOT NULL) ORDER BY c.id");
$rows=[['NOT IN с NULL',count($bad),'Нет строк']];
foreach($screen as $r){$rows[]=['NOT EXISTS',(int)$r['id'],$r['name']];}
foreach($filtered as $r){$rows[]=['NOT IN после IS NOT NULL',(int)$r['id'],$r['name']];}
$checks=['null_trap_observed'=>$bad===[],'missing_paid_customers'=>array_map('intval',array_column($screen,'id'))===[2,3],'filtered_alternative_equal'=>$filtered===$screen];
$details=['bad_result'=>$bad,'correct_result'=>$screen,'outer_id_is_not_nullable'=>true];
$db=null;
rs110Report('mysql110-related-not-exists', ['Способ', 'ID / число строк для пустого ответа', 'Результат'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-not-exists: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-not-exists: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Удалите только условие status = paid из NOT EXISTS. Тогда Борис перестанет подходить: у него есть черновик. Вера должна остаться. Объясните изменившееся требование словами.
Практический вопрос: Почему NOT IN внезапно вернул пустой результат. В вопросе ожидалось дополнение к найденным строкам, но NOT IN вернул ноль; автор подтвердил nullable-ключ в подзапросе. Это источник ситуации; технические правила сверены с PHP/MySQL, пример написан отдельно.
Получить два заказа целиком: LIMIT применить до позиций
На странице должно быть два заказа, и у каждого — все его позиции. Если применить LIMIT 2 к уже соединённым строкам, в ответе окажутся только первые две позиции заказа 101: потеряются и ложка, и весь заказ 102.
Исходные данные
| Заказ | Позиции | Количество строк |
|---|---|---|
| 101 — Анна | Чай × 2; Кружка × 1; Ложка × 1 | 3 |
| 102 — Борис | Мёд × 3; Чай × 1 | 2 |
| 103 — Вера | Нет позиций | 0 |
- Сначала выбрать родителей 101 и 102: ORDER BY id LIMIT 2
- Полученную таблицу назвать page
- Присоединить все пять позиций этих двух заказов
- PHP получает 5 строк для 2 карточек, не 2 строки
Что получится
| Способ | Заказ ID | Позиция ID | Товар | Количество |
|---|---|---|---|---|
| LIMIT после JOIN | 101 | 1 | Чай | 2 |
| LIMIT после JOIN | 101 | 2 | Кружка | 1 |
| LIMIT родителей до JOIN | 101 | 1 | Чай | 2 |
| LIMIT родителей до JOIN | 101 | 2 | Кружка | 1 |
| LIMIT родителей до JOIN | 101 | 3 | Ложка | 1 |
| LIMIT родителей до JOIN | 102 | 4 | Мёд | 3 |
| LIMIT родителей до JOIN | 102 | 5 | Чай | 1 |
SQL, который выражает задачу
SELECT page.id AS order_id, page.customer, l.id AS line_id,
l.title, l.qty
FROM (
SELECT id, customer FROM mysql110_rs_orders
ORDER BY id LIMIT 2
) AS page
LEFT JOIN mysql110_rs_lines AS l ON l.order_id = page.id
ORDER BY page.id, l.id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
Подзапрос в FROM создаёт производную таблицу: результат внутреннего SELECT используется как вход внешнего. Здесь page содержит ровно два родительских ID и имена покупателей. LEFT JOIN добавляет позиции только этих заказов. LIMIT ограничивает карточки до размножения строк, а не обрезает их содержимое после соединения.
Внутренний ORDER BY определяет, какие родители входят на страницу; внешний ORDER BY page.id,l.id определяет порядок готового ответа. Обе части нужны по своему смыслу. Имена order_id и line_id явно различают идентификатор заказа и идентификатор позиции. Пять строк SQL не означают пять заказов: при формировании HTML их группируют по order_id. Это отличается от произвольного удаления дублей родителя вместе с полезными дочерними данными.
Границы и частые ошибки
Два родителя могут иметь очень много позиций, поэтому LIMIT 2 не гарантирует небольшой ответ. Для таких экранов показывайте только итог/счётчик или загружайте подробности отдельно. Реальный порядок часто задаётся датой и ID как дополнительным ключом; без устойчивого ORDER BY страница не определена. Параметры курсора, фильтры доступа и сортировка должны применяться к родительскому SELECT, как в главе подготовленных фильтров.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
rs110OrderLines($db);
$bad=rs110Rows($db,'SELECT o.id AS order_id,l.id AS line_id,l.title,l.qty FROM mysql110_rs_orders AS o LEFT JOIN mysql110_rs_lines AS l ON l.order_id=o.id ORDER BY o.id,l.id LIMIT 2');
$screen=rs110Rows($db,'SELECT page.id AS order_id,page.customer,l.id AS line_id,l.title,l.qty FROM (SELECT id,customer FROM mysql110_rs_orders ORDER BY id LIMIT 2) AS page LEFT JOIN mysql110_rs_lines AS l ON l.order_id=page.id ORDER BY page.id,l.id');
$rows=[];foreach($bad as $r){$rows[]=['LIMIT после JOIN',(int)$r['order_id'],(int)$r['line_id'],$r['title'],(int)$r['qty']];}
foreach($screen as $r){$rows[]=['LIMIT родителей до JOIN',(int)$r['order_id'],(int)$r['line_id'],$r['title'],(int)$r['qty']];}
$checks=['bad_page_incomplete'=>array_map('intval',array_column($bad,'line_id'))===[1,2],'two_parents'=>array_values(array_unique(array_map('intval',array_column($screen,'order_id'))))===[101,102],'all_five_lines'=>array_map('intval',array_column($screen,'line_id'))===[1,2,3,4,5]];
$details=['parent_limit'=>2,'good_joined_row_count'=>count($screen),'excluded_parent_id'=>103];
$db=null;
rs110Report('mysql110-related-parent-page', ['Способ', 'Заказ ID', 'Позиция ID', 'Товар', 'Количество'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-parent-page: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-parent-page: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Измените внутренний LIMIT на 3. Заказ 103 должен появиться одной строкой с NULL в полях позиции благодаря LEFT JOIN. Для этого в полной программе учитывайте NULL при преобразовании line_id/qty: это отсутствие позиции, а не позиция с ID 0.
Собрать страницу и позиции двумя запросами вместо N + 1
HTML уже устроен как список карточек с вложенным массивом items. Можно запросить три заказа, а затем отдельно позиции каждого: получится 1 + 3 обращения. Сохраним ту же форму данных двумя запросами на всю страницу.
Исходные данные
| Заказ | Позиции | Количество строк |
|---|---|---|
| 101 — Анна | Чай × 2; Кружка × 1; Ложка × 1 | 3 |
| 102 — Борис | Мёд × 3; Чай × 1 | 2 |
| 103 — Вера | Нет позиций | 0 |
- Первый SELECT возвращает страницу ID 101, 102, 103
- Для каждого ID заранее создать items=[]
- Один SELECT с IN(?,?,?) возвращает пять позиций
- Разложить позиции по order_id; карточка 103 остаётся пустой
Что получится
| Способ | Заказ ID | Покупатель | Позиций | Содержимое карточки | Запросов на всю страницу |
|---|---|---|---|---|---|
| N + 1 | 101 | Анна | 3 | Чай × 2; Кружка × 1; Ложка × 1 | 4 |
| N + 1 | 102 | Борис | 2 | Мёд × 3; Чай × 1 | 4 |
| N + 1 | 103 | Вера | 0 | Нет позиций | 4 |
| Пакетное чтение | 101 | Анна | 3 | Чай × 2; Кружка × 1; Ложка × 1 | 2 |
| Пакетное чтение | 102 | Борис | 2 | Мёд × 3; Чай × 1 | 2 |
| Пакетное чтение | 103 | Вера | 0 | Нет позиций | 2 |
SQL, который выражает задачу
SELECT id, customer FROM mysql110_rs_orders ORDER BY id LIMIT 3;
-- Второй запрос получает только ID найденной страницы:
SELECT id, order_id, title, qty FROM mysql110_rs_lines
WHERE order_id IN (?, ?, ?)
ORDER BY order_id, id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
Пример реально выполняет оба варианта: один запрос родителей плюс три запроса детей (N + 1) и затем один запрос родителей плюс один общий запрос детей. Счётчики увеличиваются непосредственно рядом с этими вызовами; создание учебных таблиц в счётчик экрана не включено. В отчёте видно 4 против 2 обращений и одинаковые карточки. Это подсчёт запросов, не измерение времени или обещание конкретного ускорения.
Для IN создаётся столько знаков ?, сколько ID пришло с ограниченной страницы. Сами ID передаются в execute отдельным массивом: строку SQL образуют только запятые и маркеры. Одному ? нельзя передать список через строку «101,102,103». Массив $cards индексируется ID заказа; цикл по детям дописывает только его items. Отсутствие детей сохраняется как пустой массив, потому что карточки созданы заранее. Для пустой страницы второй SELECT пропускается — не возникает IN().
Границы и частые ошибки
Два отдельных SELECT могут увидеть изменения между собой. Если странице нужен единый согласованный снимок, примените подходящую транзакцию и уровень изоляции либо один запрос; это не проверяется данным однопоточным примером. Большой IN и огромные дочерние коллекции нужно ограничивать или разбивать по задаче. Здесь fetchAll собирает только маленькую страницу; это не совет загружать весь каталог и фильтровать PHP-кодом. Пакетное чтение не заменяет проверку доступа к родителям.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
rs110OrderLines($db);
$naiveQueries=0; $naiveCards=[];
$naiveParents=rs110Rows($db,'SELECT id,customer FROM mysql110_rs_orders ORDER BY id LIMIT 3');++$naiveQueries;
foreach($naiveParents as $p){
$list=rs110Rows($db,'SELECT title,qty FROM mysql110_rs_lines WHERE order_id=? ORDER BY id',[(int)$p['id']]);++$naiveQueries;
$items=[];foreach($list as $item){$items[]=$item['title'].' × '.(int)$item['qty'];}
$naiveCards[(int)$p['id']]=['customer'=>$p['customer'],'items'=>$items];
}
$dataQueries=0;
$parents=rs110Rows($db,'SELECT id,customer FROM mysql110_rs_orders ORDER BY id LIMIT 3');++$dataQueries;
$ids=array_map('intval',array_column($parents,'id')); $cards=[];
foreach($parents as $p){$cards[(int)$p['id']]=['customer'=>$p['customer'],'items'=>[]];}
$children=[];
if($ids!==[]){
$marks=implode(',',array_fill(0,count($ids),'?'));
$children=rs110Rows($db,'SELECT id,order_id,title,qty FROM mysql110_rs_lines WHERE order_id IN ('.$marks.') ORDER BY order_id,id',$ids);++$dataQueries;
}
foreach($children as $r){$cards[(int)$r['order_id']]['items'][]=$r['title'].' × '.(int)$r['qty'];}
$rows=[];foreach([['N + 1',$naiveCards,$naiveQueries],['Пакетное чтение',$cards,$dataQueries]] as [$mode,$source,$queryCount]){foreach($source as $id=>$card){$rows[]=[$mode,$id,$card['customer'],count($card['items']),$card['items']===[]?'Нет позиций':implode('; ',$card['items']),$queryCount];}}
$checks=['two_screen_queries'=>$dataQueries===2,'naive_four_queries'=>$naiveQueries===4,'same_cards'=>$naiveCards===$cards,'parent_page_preserved'=>array_keys($cards)===[101,102,103],'empty_child_list_preserved'=>$cards[103]['items']===[],'all_children_assigned'=>count($cards[101]['items'])===3 && count($cards[102]['items'])===2 && count($children)===5];
$details=['executed_screen_queries'=>$dataQueries,'executed_naive_queries'=>$naiveQueries,'setup_queries_excluded'=>true,'parent_count'=>count($parents),'child_count'=>count($children),'empty_page_would_skip_child_query'=>true,'timing_benchmark'=>false];
$db=null;
rs110Report('mysql110-related-batch-children', ['Способ', 'Заказ ID', 'Покупатель', 'Позиций', 'Содержимое карточки', 'Запросов на всю страницу'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-batch-children: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-batch-children: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Поставьте LIMIT 1. Оба варианта вернут карточку Анны. N + 1 тогда даст 2 обращения, как и пакетный вариант: выигрыш связан с числом родителей, а не магическим словом IN.
Показать ноль позиций у пустого заказа
В строке заказа нужен значок «0 позиций». LEFT JOIN сохраняет пустой заказ Веры, однако COUNT(*) показывает для него 1. Посчитаем именно найденные дочерние строки.
Исходные данные
| Заказ | Позиции | Количество строк |
|---|---|---|
| 101 — Анна | Чай × 2; Кружка × 1; Ложка × 1 | 3 |
| 102 — Борис | Мёд × 3; Чай × 1 | 2 |
| 103 — Вера | Нет позиций | 0 |
- Для заказа 103 нет ни одной строки mysql110_rs_lines
- LEFT JOIN сохраняет родителя с l.id=NULL
- COUNT(*) считает сохранённую строку: 1
- COUNT(l.id) пропускает NULL: 0 позиций
Что получится
| Заказ ID | Покупатель | COUNT(*) после JOIN | COUNT(l.id): позиций |
|---|---|---|---|
| 101 | Анна | 3 | 3 |
| 102 | Борис | 2 | 2 |
| 103 | Вера | 1 | 0 |
SQL, который выражает задачу
SELECT o.id, o.customer, COUNT(l.id) AS line_count
FROM mysql110_rs_orders AS o
LEFT JOIN mysql110_rs_lines AS l ON l.order_id = o.id
GROUP BY o.id, o.customer
ORDER BY o.id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
COUNT(*) считает строки полученного набора. После LEFT JOIN пустой родитель всё равно представлен одной строкой, у которой поля справа равны NULL. COUNT(l.id) считает значения идентификатора позиции, отличные от NULL; числовой 0 тоже учитывается. Поскольку id — обязательный первичный ключ реальной позиции, значение, отличное от NULL, здесь означает настоящее совпадение.
GROUP BY o.id,o.customer сворачивает связанные строки в одну итоговую строку на заказ. Внешний ORDER BY задаёт порядок отчёта. COALESCE(COUNT(*),0) проблему не исправит: значение 1 уже не NULL. Нельзя заменять COUNT(l.id) на COUNT(COALESCE(l.id,0)): подстановка нуля превратит отсутствующую позицию в значение выражения, отличное от NULL, которое снова будет посчитано.
Границы и частые ошибки
Не считайте nullable-примечание вместо ID: у существующей позиции примечание тоже может отсутствовать. Если добавляете условие по дочерней таблице и хотите сохранить пустых родителей, проверяйте, должно ли оно быть в ON, а не в WHERE. Несколько независимых дочерних JOIN могут размножить даже ID, отличные от NULL — следующий пример разбирает этот отдельный случай.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
rs110OrderLines($db);
$summary=rs110Rows($db,'SELECT o.id,o.customer,COUNT(*) AS joined_rows,COUNT(l.id) AS line_count FROM mysql110_rs_orders AS o LEFT JOIN mysql110_rs_lines AS l ON l.order_id=o.id GROUP BY o.id,o.customer ORDER BY o.id');
$rows=[];foreach($summary as $r){$rows[]=[(int)$r['id'],$r['customer'],(int)$r['joined_rows'],(int)$r['line_count']];}
$checks=['empty_parent_is_zero'=>$rows[2]===[103,'Вера',1,0],'nonempty_counts'=>$rows[0][3]===3 && $rows[1][3]===2,'all_parents_visible'=>count($rows)===3];
$details=['empty_join_output'=>rs110Rows($db,'SELECT o.id AS order_id,l.id AS line_id FROM mysql110_rs_orders AS o LEFT JOIN mysql110_rs_lines AS l ON l.order_id=o.id WHERE o.id=103')];
$db=null;
rs110Report('mysql110-related-count-children', ['Заказ ID', 'Покупатель', 'COUNT(*) после JOIN', 'COUNT(l.id): позиций'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-count-children: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-count-children: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Добавьте позицию к заказу 103 и предскажите оба счётчика: теперь оба будут равны 1. Затем удалите её только из учебных данных и снова объясните разницу между строкой JOIN и реальной позицией.
Практический вопрос: Как получить ноль вместо единицы после LEFT JOIN. Для пользователя без входов COUNT(*) после LEFT JOIN показывал единицу. Наш пример использует пустой заказ и ненулевой ID позиции. Это источник ситуации; технические правила сверены с PHP/MySQL, пример написан отдельно.
Посчитать позиции и платежи без перемножения двух связей
Карточка заказа показывает стоимость всех позиций и сумму всех платежей. У заказа 101 две позиции по 10000 копеек и три платежа по 5000. Правильно: начислено 20000, оплачено 15000, осталось 5000.
Исходные данные
| Заказ | Суммы позиций, коп. | Суммы платежей, коп. |
|---|---|---|
| 101 | 10000 + 10000 | 5000 + 5000 + 5000 |
| 102 | 30000 | 30000 |
| 103 | Нет позиций | Нет платежей |
- Заказ 101: две независимые ветки, 2 позиции и 3 платежа
- Прямой JOIN создаёт 2 × 3 = 6 сочетаний
- Сначала свести каждую ветку к одной строке на order_id
- Соединить 20000 и 15000; остаток = 5000
Что получится
| Способ | Заказ ID | Позиции, коп. | Оплачено, коп. | Остаток, коп. |
|---|---|---|---|---|
| Две связи без предварительного SUM | 101 | 60000 | 30000 | 30000 |
| SUM(DISTINCT сумма) теряет равные операции | 101 | 10000 | 5000 | 5000 |
| Каждая ветка сначала GROUP BY order_id | 101 | 20000 | 15000 | 5000 |
| Каждая ветка сначала GROUP BY order_id | 102 | 30000 | 30000 | 0 |
| Каждая ветка сначала GROUP BY order_id | 103 | 0 | 0 | 0 |
SQL, который выражает задачу
SELECT o.id, COALESCE(i.items, 0) AS items,
COALESCE(p.paid, 0) AS paid
FROM mysql110_rs_orders AS o
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS items
FROM mysql110_rs_items GROUP BY order_id
) AS i ON i.order_id = o.id
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS paid
FROM mysql110_rs_payments GROUP BY order_id
) AS p ON p.order_id = o.id
ORDER BY o.id;Это объясняющий фрагмент для уже созданных учебных таблиц. SQL сначала выполняет сервер, затем PHP читает результат. Полная программа ниже сама создаёт входные данные и сохраняет HTML/JSON.
Платёж не привязан к конкретной позиции: обе таблицы независимо ссылаются на заказ. Поэтому прямое соединение сочетает каждую позицию с каждым платежом. Сумма позиций повторяется три раза и становится 60000, сумма платежей повторяется дважды и становится 30000. GROUP BY заказа после такого JOIN не восстанавливает потерянный смысл: SUM уже видит повторённые значения.
Каждый подзапрос сначала GROUP BY order_id получает одну строку своей ветки. Затем LEFT JOIN связывает два итога с заказом. COALESCE заменяет отсутствие агрегированной строки нулём, чтобы пустой заказ 103 тоже получил понятный отчёт. В полной программе debt — разность этих двух итогов. Суммы хранятся целыми копейками; float не участвует.
SUM(DISTINCT amount_cents) не знает идентификаторов операций. У заказа две разные позиции с одинаковой суммой и три разных платежа с одинаковой суммой: DISTINCT оставит только 10000 и 5000. Даже случайно совпавший остаток 5000 не делает такие начисления/платежи правильными. Проверка сравнивает все итоговые столбцы, а не одну разность.
Границы и частые ошибки
Перед агрегацией задайте нужные правила: учитывать ли отменённые позиции, возвраты и неподтверждённые платежи. Для разных валют итоги нельзя просто складывать без политики валюты/пересчёта. Если запрос нужен только для одного заказа или страницы, ограничьте вычисления соответствующими ID; полный учебный отчёт содержит всего три заказа. Здесь нет проверки многопользовательского согласованного снимка.
Полная самостоятельная программа и файлы
<?php
declare(strict_types=1);
require __DIR__.'/rs110-helper.php';
$db=rs110Connect();
$runtime=['php'=>PHP_VERSION,'client'=>$db->getAttribute(PDO::ATTR_CLIENT_VERSION),'server'=>$db->getAttribute(PDO::ATTR_SERVER_VERSION)];
$details=[];
$db->exec('CREATE TEMPORARY TABLE mysql110_rs_orders (id INT PRIMARY KEY) ENGINE=InnoDB');
$db->exec('CREATE TEMPORARY TABLE mysql110_rs_items (id INT PRIMARY KEY,order_id INT NOT NULL,amount_cents INT NOT NULL,KEY(order_id)) ENGINE=InnoDB');
$db->exec('CREATE TEMPORARY TABLE mysql110_rs_payments (id INT PRIMARY KEY,order_id INT NOT NULL,amount_cents INT NOT NULL,KEY(order_id)) ENGINE=InnoDB');
$db->exec('INSERT INTO mysql110_rs_orders VALUES (101),(102),(103)');
$db->exec('INSERT INTO mysql110_rs_items VALUES (1,101,10000),(2,101,10000),(3,102,30000)');
$db->exec('INSERT INTO mysql110_rs_payments VALUES (11,101,5000),(12,101,5000),(13,101,5000),(14,102,30000)');
$bad=rs110Rows($db,'SELECT o.id,COUNT(*) AS joined_rows,SUM(i.amount_cents) AS items,SUM(p.amount_cents) AS paid,SUM(DISTINCT i.amount_cents) AS distinct_items,SUM(DISTINCT p.amount_cents) AS distinct_paid FROM mysql110_rs_orders AS o JOIN mysql110_rs_items AS i ON i.order_id=o.id JOIN mysql110_rs_payments AS p ON p.order_id=o.id WHERE o.id=101 GROUP BY o.id')[0];
$good=rs110Rows($db,'SELECT o.id,COALESCE(i.items,0) AS items,COALESCE(p.paid,0) AS paid,COALESCE(i.items,0)-COALESCE(p.paid,0) AS debt FROM mysql110_rs_orders AS o LEFT JOIN (SELECT order_id,SUM(amount_cents) AS items FROM mysql110_rs_items GROUP BY order_id) AS i ON i.order_id=o.id LEFT JOIN (SELECT order_id,SUM(amount_cents) AS paid FROM mysql110_rs_payments GROUP BY order_id) AS p ON p.order_id=o.id ORDER BY o.id');
$rows=[['Две связи без предварительного SUM',101,(int)$bad['items'],(int)$bad['paid'],(int)$bad['items']-(int)$bad['paid']],['SUM(DISTINCT сумма) теряет равные операции',101,(int)$bad['distinct_items'],(int)$bad['distinct_paid'],(int)$bad['distinct_items']-(int)$bad['distinct_paid']]];
$actual=[];foreach($good as $r){$v=[(int)$r['id'],(int)$r['items'],(int)$r['paid'],(int)$r['debt']];$actual[]=$v;$rows[]=array_merge(['Каждая ветка сначала GROUP BY order_id'],$v);}
$checks=['six_combinations'=>(int)$bad['joined_rows']===6,'inflated_sums'=>(int)$bad['items']===60000 && (int)$bad['paid']===30000,'distinct_is_not_fix'=>(int)$bad['distinct_items']===10000 && (int)$bad['distinct_paid']===5000,'exact_totals'=>$actual===[[101,20000,15000,5000],[102,30000,30000,0],[103,0,0,0]]];
$details=['original_order101_item_amounts'=>[10000,10000],'original_order101_payment_amounts'=>[5000,5000,5000],'joined_combinations'=>(int)$bad['joined_rows'],'unit'=>'integer cents'];
$db=null;
rs110Report('mysql110-related-independent-totals', ['Способ', 'Заказ ID', 'Позиции, коп.', 'Оплачено, коп.', 'Остаток, коп.'], $rows, $checks, $runtime, $details);
echo 'mysql110-related-independent-totals: saved result.html and result.json
';
Ожидаемый результат
mysql110-related-independent-totals: saved result.html and result.json
Откройте result.html: получите конкретные строки таблицы выше. Код создания учебных таблиц, подключение и helper включены в этот ZIP; не требуются результаты предыдущих программ.
Открыть фактический HTML-результат · Скачать результат JSON
Попробуйте сами. Добавьте четвёртый отдельный платёж 5000 к заказу 101. Правильная оплаченная сумма станет 20000, остаток — 0. SUM(DISTINCT) по-прежнему покажет только 5000: объясните, почему уникальность суммы не равна уникальности платежа.
Практический вопрос: Две независимые связи увеличили суммы платежей. В вопросе отдельные платежи и сборы соединялись с одной заявкой; появление второго сбора удваивало платежи. Наши исходные суммы и решения написаны заново. Это источник ситуации; технические правила сверены с PHP/MySQL, пример написан отдельно.
Дальше: выбрать последний статус и построить сводку
Для наличия связанной строки теперь есть EXISTS, для страницы — выбранные родители, для независимых сумм — отдельная агрегация. Когда нужна конкретная последняя запись или накопление по времени, переходите к оконным функциям и календарным отчётам. Передавать параметры безопасно помогают подготовленные фильтры, а получить читаемый массив — формы результата PDO.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


