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

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

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

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

Глава 114 · После таблиц, JOIN и первых подготовленных SELECT

MySQL: связанные данные без дублей, обрезанных заказов и неверных сумм

EXISTS и NULL, страница родителей, два запроса вместо N + 1, нулевые счётчики и независимые суммы позиций/платежей.

Вы уже умеете связать таблицы, но экрану нужна определённая форма: одна карточка покупателя, две карточки заказа со всеми позициями или две независимые суммы. SQL может успешно выполниться и вернуть не ту форму. Здесь сверяем запрос с тем, что должен увидеть человек.

Сначала назовите единицу результата
  1. Покупатель: нужен ли хотя бы один оплаченный заказ?
  2. Страница: два заказа, у каждого все позиции
  3. Счётчик: реальные позиции, включая ноль
  4. Две суммы: отдельно позиции и отдельно платежи
ТерминЧто означает в этой главе
РодительПокупатель или заказ, чья карточка нужна экрану
Дочерние строкиЗаказы покупателя либо позиции/платежи заказа
Один ко многим, 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Гостевая запись не относится ни к одному из трёх
Как данные доходят до результата
  1. Анна → оплаченные заказы 101 и 102
  2. EXISTS проверяет наличие подходящей строки
  3. Во внешний SELECT попадает сама Анна один раз
  4. В 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 не должен заставить весь список исчезнуть.

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

ПокупательЕго заказыЧто означает
1 — Анна101 paid; 102 paidДве оплаты, одна карточка покупателя
2 — Борис103 draftЗаказ есть, оплаты нет
3 — ВераНет заказовТоже нет оплаты
Без покупателя104 paid, customer_id=NULLГостевая запись не относится ни к одному из трёх
Как данные доходят до результата
  1. Оплаченные customer_id: 1, 1, NULL
  2. NOT IN с NULL не подтверждает отсутствие ID 2 или 3
  3. NOT EXISTS ищет оплату именно текущего покупателя
  4. Результат: Борис и Вера

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

СпособID / число строк для пустого ответаРезультат
NOT IN с NULL0Нет строк
NOT EXISTS2Борис
NOT EXISTS3Вера
NOT IN после IS NOT NULL2Борис
NOT IN после IS NOT NULL3Вера

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 без правила предметной области. В примере «нет оплаты» включает наличие только черновиков; это не синоним «нет никаких заказов».

Полная самостоятельная программа и файлы
Найти покупателей без оплаты, даже если в заказах есть 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=[];
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 2 к уже соединённым строкам, в ответе окажутся только первые две позиции заказа 101: потеряются и ложка, и весь заказ 102.

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

ЗаказПозицииКоличество строк
101 — АннаЧай × 2; Кружка × 1; Ложка × 13
102 — БорисМёд × 3; Чай × 12
103 — ВераНет позиций0
Как данные доходят до результата
  1. Сначала выбрать родителей 101 и 102: ORDER BY id LIMIT 2
  2. Полученную таблицу назвать page
  3. Присоединить все пять позиций этих двух заказов
  4. PHP получает 5 строк для 2 карточек, не 2 строки

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

СпособЗаказ IDПозиция IDТоварКоличество
LIMIT после JOIN1011Чай2
LIMIT после JOIN1012Кружка1
LIMIT родителей до JOIN1011Чай2
LIMIT родителей до JOIN1012Кружка1
LIMIT родителей до JOIN1013Ложка1
LIMIT родителей до JOIN1024Мёд3
LIMIT родителей до JOIN1025Чай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, как в главе подготовленных фильтров.

Полная самостоятельная программа и файлы
Получить два заказа целиком: LIMIT применить до позицийСамостоятельный пример
<?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.

HTML уже устроен как список карточек с вложенным массивом items. Можно запросить три заказа, а затем отдельно позиции каждого: получится 1 + 3 обращения. Сохраним ту же форму данных двумя запросами на всю страницу.

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

ЗаказПозицииКоличество строк
101 — АннаЧай × 2; Кружка × 1; Ложка × 13
102 — БорисМёд × 3; Чай × 12
103 — ВераНет позиций0
Как данные доходят до результата
  1. Первый SELECT возвращает страницу ID 101, 102, 103
  2. Для каждого ID заранее создать items=[]
  3. Один SELECT с IN(?,?,?) возвращает пять позиций
  4. Разложить позиции по order_id; карточка 103 остаётся пустой

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

СпособЗаказ IDПокупательПозицийСодержимое карточкиЗапросов на всю страницу
N + 1101Анна3Чай × 2; Кружка × 1; Ложка × 14
N + 1102Борис2Мёд × 3; Чай × 14
N + 1103Вера0Нет позиций4
Пакетное чтение101Анна3Чай × 2; Кружка × 1; Ложка × 12
Пакетное чтение102Борис2Мёд × 3; Чай × 12
Пакетное чтение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-кодом. Пакетное чтение не заменяет проверку доступа к родителям.

Полная самостоятельная программа и файлы
Собрать страницу и позиции двумя запросами вместо N + 1Самостоятельный пример
<?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; Ложка × 13
102 — БорисМёд × 3; Чай × 12
103 — ВераНет позиций0
Как данные доходят до результата
  1. Для заказа 103 нет ни одной строки mysql110_rs_lines
  2. LEFT JOIN сохраняет родителя с l.id=NULL
  3. COUNT(*) считает сохранённую строку: 1
  4. COUNT(l.id) пропускает NULL: 0 позиций

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

Заказ IDПокупательCOUNT(*) после JOINCOUNT(l.id): позиций
101Анна33
102Борис22
103Вера10

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.

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

ЗаказСуммы позиций, коп.Суммы платежей, коп.
10110000 + 100005000 + 5000 + 5000
1023000030000
103Нет позицийНет платежей
Как данные доходят до результата
  1. Заказ 101: две независимые ветки, 2 позиции и 3 платежа
  2. Прямой JOIN создаёт 2 × 3 = 6 сочетаний
  3. Сначала свести каждую ветку к одной строке на order_id
  4. Соединить 20000 и 15000; остаток = 5000

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

СпособЗаказ IDПозиции, коп.Оплачено, коп.Остаток, коп.
Две связи без предварительного SUM101600003000030000
SUM(DISTINCT сумма) теряет равные операции1011000050005000
Каждая ветка сначала GROUP BY order_id10120000150005000
Каждая ветка сначала GROUP BY order_id10230000300000
Каждая ветка сначала GROUP BY order_id103000

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. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.