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

Дерево руководства
Глава 45 · После таблиц и связей
MySQL: получить нужный экран, а не всю базу в PHP
SELECT * и явные поля, 100000 строк против страницы из 20, JOIN и алиасы, CRUD, GROUP BY/HAVING, реальные планы EXPLAIN.
Сначала экран, затем запрос
Принцип этой главы: запрос должен вернуть данные, которые нужны сейчас на экране. Каталогу — страницу товаров, карточке заказа — его позиции, истории клиента — его заказы, сводке — агрегаты. Не получаем всю базу в PHP, чтобы затем искать нужные строки, сортировать большой массив и выбрасывать почти всё.
| Экран | Источники и связи | Форма ответа |
|---|---|---|
| Каталог категории | products; JOIN не нужен, если нужные данные уже в этой таблице | id,name,price; WHERE; ORDER BY; LIMIT 20 |
| Карточка заказа 101 | orders → customers; orders → order_items → products | Только позиции заказа 101 с количеством и ценой покупки |
| История клиента 1 | orders WHERE customer_id=1, сумма позиций по order_id | Номера и итоги заказов, ограниченная страница |
| Сводка продаж | Оплаченные orders, GROUP BY customer_id | Одна строка с числом заказов и суммой на клиента |
SELECT определяет поля ответа; FROM и JOIN — откуда они берутся и как связаны; WHERE — какие строки подходят; GROUP BY/HAVING — нужна ли сводка; ORDER BY — порядок; LIMIT — объём страницы. Это логическая модель смысла, не обещание физического порядка работы оптимизатора. Связи и ключи описаны в предыдущей SQL-главе.
- Назовите экран и его поля
- Определите нужные таблицы и кардинальность связей
- Ограничьте строки и столбцы на сервере
- Сделайте порядок устойчивым, задайте границу страницы
- Проверьте реальный результат и EXPLAIN
- PHP читает и оформляет уже нужные строки
SELECT * и явные поля: что действительно получает PHP
Карточке каталога нужны название и цена. В таблице дополнительно лежат длинное описание и служебная заметка. Сравним, что реально приходит в PHP при SELECT * и при SELECT name,price, затем добавим новую колонку и посмотрим, какой договор изменится.
Исходные данные
| Поле | Входное значение |
|---|---|
| id | 1 |
| name | Чай |
| price | 120.00 |
| description | Строка «Long product description. » повторена 100 раз (2600 ASCII-символов) |
| internal_note | draft supplier note |
На экран должны попасть только Чай и 120.00. Длинный текст создаёт сервер через REPEAT; вход воспроизводим без скачивания чужих данных.
Что получится
Данные для карточки
| name | price |
|---|---|
| Чай | 120.00 |
Полная таблица имён полей и фактических размеров находится в сохранённом result.html ниже. Число байт измеряется программой; его не подставляют вручную как универсальную норму.
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
SELECT *
FROM mysql106_sql_projection_products WHERE id=1;
SELECT name,price
FROM mysql106_sql_projection_products WHERE id=1;
-- Позже структура таблицы расширяется:
ALTER TABLE mysql106_sql_projection_products
ADD COLUMN supplier_code VARCHAR(20) NULL;Как читается запрос и почему он решает задачу
Звёздочка раскрывается в колонки источника. Явная проекция — список нужных полей после SELECT — фиксирует форму результата и не требует доставлять остальные значения приложению. WHERE id=1 в обоих запросах одинаков: здесь сравнивается ширина одной строки, а не число строк.
| Последствие SELECT * | Что показываем или учитываем |
|---|---|
| Лишние данные | result.html показывает фактические имена полей и число байт JSON после json_encode одной полученной строки. Это размер сериализованного UTF-8-результата в PHP, а не измерение MySQL-протокола или сетевого трафика. |
| Изменение схемы | После ADD supplier_code у SELECT * появляется новый ключ; name,price остаются прежними. Потребитель, завязанный на позиции/полный набор полей, неожиданно меняет договор. |
| Служебные поля | Не следует автоматически отдавать весь fetch_assoc как API-ответ: новое внутреннее поле может начать публиковаться. Явный список не заменяет проверку прав доступа к строкам. |
| JOIN и одинаковые имена | Два поля id в fetch_assoc конфликтуют: позднее перекрывает раннее. Используйте AS customer_id и AS order_id; ниже есть настоящий пример такого результата. |
| Индекс | Если все нужные поля входят в подходящий индекс, сервер может получить их без отдельного чтения строки таблицы. SELECT * с description лишает этот индекс покрытия; фактические планы сравниваются в следующем сценарии. |
Плох не сам символ в любом контексте. SELECT * уместен при разовом исследовании таблицы или когда действительно нужны все поля и изменение схемы согласовано с потребителем. Он не означает автоматически полный обход таблицы: WHERE, индекс, план и LIMIT — отдельные факторы. COUNT(*) означает число строк и не загружает все колонки в PHP. Эти две записи нельзя смешивать в одном запрете.
Ошибки и границы
Замена * на name,price не ограничит миллион строк без WHERE/LIMIT. Также не обещаем, что каждый явный список ускорит запрос: если нужны все поля, выбирать их по именам полезно для стабильности договора, а выигрыш зависит от плана и объёма. В примере размер JSON — наблюдаемая цена лишней формы данных, не универсальный коэффициент производительности.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_projection_products','id INT PRIMARY KEY, name VARCHAR(60) NOT NULL, price DECIMAL(8,2) NOT NULL, description TEXT NOT NULL, internal_note VARCHAR(60) NOT NULL');
$db->query("INSERT INTO mysql106_sql_projection_products VALUES (1,'Чай',120.00,REPEAT('Long product description. ',100),'draft supplier note')");
$star=$db->query('SELECT * FROM mysql106_sql_projection_products WHERE id=1')->fetch_assoc();
$small=$db->query('SELECT name,price FROM mysql106_sql_projection_products WHERE id=1')->fetch_assoc();
$encode=fn(array $row)=>json_encode($row,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);
$starBytes=strlen($encode($star));$smallBytes=strlen($encode($small));
$db->query('ALTER TABLE mysql106_sql_projection_products ADD COLUMN supplier_code VARCHAR(20) NULL');
$afterStar=$db->query('SELECT * FROM mysql106_sql_projection_products WHERE id=1')->fetch_assoc();
$afterSmall=$db->query('SELECT name,price FROM mysql106_sql_projection_products WHERE id=1')->fetch_assoc();
$blocks=[sql106Block('Данные для карточки',['name','price'],[[$small['name'],(string)$small['price']]]),sql106Block('Форма результата',['Запрос','Поля до ALTER','Поля после ALTER','JSON UTF-8, байт до ALTER'],[['SELECT *',implode(', ',array_keys($star)),implode(', ',array_keys($afterStar)),(string)$starBytes],['SELECT name,price',implode(', ',array_keys($small)),implode(', ',array_keys($afterSmall)),(string)$smallBytes]])];
$checks=['same_required_values'=>$star['name']===$small['name'] && $star['price']===$small['price'],'projection_two_columns'=>count($small)===2,'star_more_json_bytes'=>$starBytes>$smallBytes,'explicit_shape_stable'=>array_keys($small)===array_keys($afterSmall),'star_shape_changed'=>count($afterStar)===count($star)+1];
$diagnostics=['measurement'=>'strlen(json_encode(one fetched row, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)): serialized UTF-8, not MySQL wire traffic','star_json_bytes'=>$starBytes,'projection_json_bytes'=>$smallBytes,'description_characters'=>strlen($star['description'])];
sql106Report('mysql106-sql-projection',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "projection: SQL results saved in result.html and result.json\n";
Ожидаемый результат
projection: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Сохранённый результат реального запуска
Данные для карточки
| name | price |
|---|---|
| Чай | 120.00 |
Форма результата
| Запрос | Поля до ALTER | Поля после ALTER | JSON UTF-8, байт до ALTER |
|---|---|---|---|
| SELECT * | id, name, price, description, internal_note | id, name, price, description, internal_note, supplier_code | 2698 |
| SELECT name,price | name, price | name, price | 34 |
Попробуйте сами
Для карточки товара разрешите description только на отдельном подробном экране. Для списка оставьте id,name,price. Сначала перечислите потребности двух экранов, затем напишите два разных SELECT вместо единого «получить всё на всякий случай». Собственные численные результаты смотрите по ссылке на result.html.
Ситуация из практического обсуждения: Why is SELECT * considered harmful?. Разработчик спрашивает, почему явные поля полезнее звёздочки и что делать, если нужны все колонки. Показываем действительные поля, изменение схемы и размер JSON; не переносим неверное обобщение, будто звёздочка всегда означает полный обход таблицы. Обсуждение помогает выбрать задачу; правила MySQL проверены по первичным источникам, код и данные написаны для этой главы.
Из 100 000 товаров получить ровно страницу каталога
Страница должна показать 20 активных товаров категории 7, начиная с дешёвых. В базе 100 000 строк. Нам нужен этот экран, а не вся таблица в PHP. Сравниваем два реально выполненных пути и проверяем, что видимые 20 строк совпали полностью.
Исходные данные
Исходник создаёт 100 000 товаров одним INSERT ... SELECT. Пять соединённых наборов цифр 0..9 дают все n от 0 до 99 999: id=n+1; category_id=n%20+1; active=0 при n%3=0, иначе 1; price_cents=100+n%997; name="Product "+id; description="details " повторено десять раз. CROSS JOIN здесь строит все сочетания пяти цифр; это генератор учебных данных, а не способ запроса каталога.
| id | category_id | active | price_cents | name |
|---|---|---|---|---|
| 1 | 1 | 0 | 100 | Product 1 |
| 7 | 7 | 0 | 106 | Product 7 |
| 27 | 7 | 1 | 126 | Product 27 |
| 47 | 7 | 1 | 146 | Product 47 |
Это несколько строк для понимания формулы; полная таблица действительно создаётся на сервере, но её 100 000 строк не публикуются как огромный JSON. Результат ниже — точная первая страница из этого набора.
Что получится
Страница каталога: ровно 20 строк
| id | name | price_cents |
|---|---|---|
| 37887 | Product 37887 | 100 |
| 57827 | Product 57827 | 100 |
| 97707 | Product 97707 | 100 |
| 24927 | Product 24927 | 101 |
| 44867 | Product 44867 | 101 |
| 84747 | Product 84747 | 101 |
| 11967 | Product 11967 | 102 |
| 31907 | Product 31907 | 102 |
| 71787 | Product 71787 | 102 |
| 91727 | Product 91727 | 102 |
| 18947 | Product 18947 | 103 |
| 58827 | Product 58827 | 103 |
| 78767 | Product 78767 | 103 |
| 5987 | Product 5987 | 104 |
| 45867 | Product 45867 | 104 |
| 65807 | Product 65807 | 104 |
| 32907 | Product 32907 | 105 |
| 52847 | Product 52847 | 105 |
| 92727 | Product 92727 | 105 |
| 19947 | Product 19947 | 106 |
Что получил PHP
| Способ | Получено строк | Показано строк |
|---|---|---|
| Вся таблица → фильтр и sort в PHP | 100000 | 20 |
| WHERE → ORDER BY → LIMIT в MySQL | 20 | 20 |
Начало следующей страницы по курсору
| id | name | price_cents |
|---|---|---|
| 39887 | Product 39887 | 106 |
| 79767 | Product 79767 | 106 |
| 99707 | Product 99707 | 106 |
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
-- Экран: 20 доступных товаров категории 7, сначала недорогие.
SELECT id,name,price_cents
FROM mysql106_sql_screen_products
WHERE category_id=7 AND active=1
ORDER BY price_cents,id
LIMIT 20;
CREATE INDEX mysql106_sql_screen_catalog
ON mysql106_sql_screen_products(category_id,active,price_cents,id,name);
EXPLAIN SELECT id,name,price_cents
FROM mysql106_sql_screen_products
WHERE category_id=7 AND active=1
ORDER BY price_cents,id LIMIT 20;Как читается запрос и почему он решает задачу
- Экран: 20 активных товаров категории 7
- FROM: одна таблица products; JOIN здесь не нужен
- SELECT: id, name, price_cents — только поля карточек списка
- WHERE: category_id=7 AND active=1
- ORDER BY: price_cents, затем id для равных цен
- LIMIT 20: не более 20 строк получает PHP
Простой SQL-фрагмент выше — нужный запрос. В полном архиве сначала намеренно выполнен плохой вариант: SELECT * без фильтра, fetch_all, array_filter, usort и array_slice. PHP получает 100 000 строк, хранит их и работает со всем массивом, хотя пользователь увидит двадцать. Затем тот же результат формирует MySQL. Проверяется равенство каждой из 20 строк, а не только количества.
| Часть | Разбор |
|---|---|
| WHERE ... AND ... | Оба условия должны выполняться. Фильтр относится к сохранённым строкам. Отсеивание строк на сервере сокращает выдачу клиенту. |
| ORDER BY price_cents,id | По умолчанию ASC: сначала меньшая цена, при равенстве меньший id. Без второго ключа граница страницы при одинаковых ценах неустойчива. |
| LIMIT 20 | Ограничивает возвращаемый набор. Сам по себе не гарантирует, что сервер просмотрел только 20 строк: для фильтра и сортировки может потребоваться больше работы. |
| CREATE INDEX (...category_id,active,price_cents,id,name) | Первые поля соответствуют равенствам, следующие — сортировке. name включён, чтобы индекс мог покрывать и вывод. Более широкий индекс занимает место и удорожает INSERT/UPDATE; его выбирают по реальным запросам. |
| EXPLAIN | Показывает выбранный план: key — индекс, type — способ доступа, rows — оценка просмотренных строк, Extra — дополнительные действия. Это не фактически замеренное число строк и не время выполнения. |
Отчёт сохраняет планы до/после индекса и отдельный план SELECT * с теми же фильтром/сортировкой/лимитом. В Extra отметка Using index означает покрывающий доступ; Using index condition — другой механизм, не синоним. Наличие индекса само по себе не заставляет оптимизатор выбрать его. Не используем FORCE INDEX и не утверждаем точный cost: смотрим реальный план этого запуска.
Для следующей страницы берём последнюю пару (price_cents,id) и продолжаем после неё:
WHERE category_id=7 AND active=1
AND (price_cents > :last_price
OR (price_cents = :last_price AND id > :last_id))
ORDER BY price_cents,id LIMIT 20;Двоеточия здесь обозначают параметры подготовленного PDO-запроса; в mysqli используются ?. Полный пример подставляет только собственные целые значения уже прочитанного курсора. Скобки группируют OR, чтобы категория и active применялись к обеим ветвям. Проверка сравнивает следующую страницу с позициями 21–40 того же неизменённого набора. LIMIT 20 OFFSET 20 проще для маленьких списков; глубокий OFFSET требует пропустить много строк и страницы смещаются при вставках. Курсор тоже не создаёт снимок мира: изменение цены между запросами влияет на обход; для строгой выгрузки нужен отдельный договор согласованности.
Ошибки и границы
Увеличение memory_limit не является лечением неограниченной выборки. 512M в команде учебного архива нужны только для намеренного плохого варианта на 100 000 строк. В result.html раскрываются реальные дельты memory_get_usage(false), временные наблюдения и планы. Это один процесс с разным состоянием кеша, без времени генерации/индекса: не честный универсальный бенчмарк. Проверяем результат и 100 000 против 20 полученных строк; скорость и абсолютную память не обещаем. Даже с LIMIT серверу нужен подходящий план, иначе большой объём работы просто останется на сервере.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_screen_digits','n INT PRIMARY KEY');
$db->query('INSERT INTO mysql106_sql_screen_digits VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)');
sql106Create($db,$created,'mysql106_sql_screen_products','id INT PRIMARY KEY, category_id INT NOT NULL, active TINYINT NOT NULL, price_cents INT NOT NULL, name VARCHAR(40) NOT NULL, description VARCHAR(100) NOT NULL');
$db->query("INSERT INTO mysql106_sql_screen_products (id,category_id,active,price_cents,name,description)
SELECT n+1,MOD(n,20)+1,IF(MOD(n,3)=0,0,1),100+MOD(n,997),CONCAT('Product ',n+1),REPEAT('details ',10)
FROM (SELECT a.n+10*b.n+100*c.n+1000*d.n+10000*e.n AS n
FROM mysql106_sql_screen_digits a CROSS JOIN mysql106_sql_screen_digits b CROSS JOIN mysql106_sql_screen_digits c CROSS JOIN mysql106_sql_screen_digits d CROSS JOIN mysql106_sql_screen_digits e) seed_rows");
$query='SELECT id,name,price_cents FROM mysql106_sql_screen_products WHERE category_id=7 AND active=1 ORDER BY price_cents,id LIMIT 20';
$planBefore=$db->query('EXPLAIN '.$query)->fetch_all(MYSQLI_ASSOC);
$memoryStart=memory_get_usage(false);$timeStart=hrtime(true);
$all=$db->query('SELECT * FROM mysql106_sql_screen_products')->fetch_all(MYSQLI_ASSOC);
$received=count($all);$memoryAll=memory_get_usage(false)-$memoryStart;
$filtered=array_values(array_filter($all,fn($r)=>(int)$r['category_id']===7 && (int)$r['active']===1));
usort($filtered,fn($a,$b)=>((int)$a['price_cents']<=>(int)$b['price_cents']) ?: ((int)$a['id']<=>(int)$b['id']));
$first40=array_slice($filtered,0,40);
$naive=array_map(fn($r)=>[(string)$r['id'],$r['name'],(string)$r['price_cents']],array_slice($first40,0,20));
$nextExpected=array_map(fn($r)=>[(string)$r['id'],$r['name'],(string)$r['price_cents']],array_slice($first40,20,20));
$naiveMs=(hrtime(true)-$timeStart)/1e6;
unset($all,$filtered,$first40);gc_collect_cycles();
$db->query('CREATE INDEX mysql106_sql_screen_catalog ON mysql106_sql_screen_products(category_id,active,price_cents,id,name)');
$planAfter=$db->query('EXPLAIN '.$query)->fetch_all(MYSQLI_ASSOC);
$planStar=$db->query('EXPLAIN SELECT * FROM mysql106_sql_screen_products WHERE category_id=7 AND active=1 ORDER BY price_cents,id LIMIT 20')->fetch_all(MYSQLI_ASSOC);
$serverStart=memory_get_usage(false);$timeStart=hrtime(true);
$page=sql106Read($db,$query);$serverMs=(hrtime(true)-$timeStart)/1e6;$memoryPage=memory_get_usage(false)-$serverStart;
$last=$page[19];$price=(int)$last[2];$id=(int)$last[0];
$next=sql106Read($db,'SELECT id,name,price_cents FROM mysql106_sql_screen_products WHERE category_id=7 AND active=1 AND (price_cents>'.$price.' OR (price_cents='.$price.' AND id>'.$id.')) ORDER BY price_cents,id LIMIT 20');
$blocks=[sql106Block('Страница каталога: ровно 20 строк',['id','name','price_cents'],$page),sql106Block('Что получил PHP',['Способ','Получено строк','Показано строк'],[['Вся таблица → фильтр и sort в PHP',(string)$received,'20'],['WHERE → ORDER BY → LIMIT в MySQL',(string)count($page),'20']]),sql106Block('Начало следующей страницы по курсору',['id','name','price_cents'],array_slice($next,0,3))];
$checks=['bulk_fixture_100000'=>$received===100000,'same_visible_twenty'=>$page===$naive,'server_received_twenty'=>count($page)===20,'keyset_matches_next_page'=>$next===$nextExpected,'explain_saved'=>count($planBefore)>0 && count($planAfter)>0,'index_created'=>count(sql106Read($db,"SHOW INDEX FROM mysql106_sql_screen_products WHERE Key_name='mysql106_sql_screen_catalog'"))===5];
$diagnostics=['fixture_rows'=>$received,'php_memory_limit'=>ini_get('memory_limit'),'index_columns'=>['category_id','active','price_cents','id','name'],'plan_before'=>$planBefore,'plan_after'=>$planAfter,'plan_select_star_same_filter'=>$planStar,'memory_observations'=>['metric'=>'memory_get_usage(false) delta in this one process; allocator/cache/runtime affect results','naive_fetch_all_bytes'=>$memoryAll,'server_page_bytes'=>$memoryPage],'elapsed_observations_ms'=>['naive_fetch_filter_sort'=>$naiveMs,'server_query_fetch'=>$serverMs],'timing_caveat'=>'Index creation and data generation excluded; different cache state; not a benchmark or guaranteed speedup'];
sql106Report('mysql106-sql-screen',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "screen: SQL results saved in result.html and result.json\n";
Ожидаемый результат
screen: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Сохранённый результат реального запуска
Страница каталога: ровно 20 строк
| id | name | price_cents |
|---|---|---|
| 37887 | Product 37887 | 100 |
| 57827 | Product 57827 | 100 |
| 97707 | Product 97707 | 100 |
| 24927 | Product 24927 | 101 |
| 44867 | Product 44867 | 101 |
| 84747 | Product 84747 | 101 |
| 11967 | Product 11967 | 102 |
| 31907 | Product 31907 | 102 |
| 71787 | Product 71787 | 102 |
| 91727 | Product 91727 | 102 |
| 18947 | Product 18947 | 103 |
| 58827 | Product 58827 | 103 |
| 78767 | Product 78767 | 103 |
| 5987 | Product 5987 | 104 |
| 45867 | Product 45867 | 104 |
| 65807 | Product 65807 | 104 |
| 32907 | Product 32907 | 105 |
| 52847 | Product 52847 | 105 |
| 92727 | Product 92727 | 105 |
| 19947 | Product 19947 | 106 |
Что получил PHP
| Способ | Получено строк | Показано строк |
|---|---|---|
| Вся таблица → фильтр и sort в PHP | 100000 | 20 |
| WHERE → ORDER BY → LIMIT в MySQL | 20 | 20 |
Начало следующей страницы по курсору
| id | name | price_cents |
|---|---|---|
| 39887 | Product 39887 | 106 |
| 79767 | Product 79767 | 106 |
| 99707 | Product 99707 | 106 |
Попробуйте сами
Измените экран: категория 12, активные товары, сначала дорогие. Нужны WHERE category_id=12, ORDER BY price_cents DESC,id DESC и соответствующий знак курсора <. Проверьте запрос и индекс через EXPLAIN. Не загружайте все товары в PHP для сортировки; PHP остаются HTML-вывод и форматирование уже выбранных строк.
Изменить один товар, затем удалить только учебный черновик
Редактор каталога повышает цену одного товара и удаляет один неопубликованный черновик. Другие товары должны остаться без изменений. Затем фильтр показывает только активные позиции нужного ценового диапазона.
Исходные данные
| id | name | price | active |
|---|---|---|---|
| 1 | Чай | 120.00 | 1 |
| 2 | Кофе | 450.00 | 1 |
| 3 | Черновик | 0.00 | 0 |
Что получится
До изменения
| id | name | price | active |
|---|---|---|---|
| 1 | Чай | 120.00 | 1 |
| 2 | Кофе | 450.00 | 1 |
| 3 | Черновик | 0.00 | 0 |
После UPDATE и DELETE
| id | name | price | active |
|---|---|---|---|
| 1 | Чай | 125.00 | 1 |
| 2 | Кофе | 450.00 | 1 |
Подходящие под фильтр
| id | name |
|---|---|
| 1 | Чай |
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
INSERT INTO mysql106_sql_crud_products (id,name,price,active)
VALUES (1,'Чай',120.00,1),(2,'Кофе',450.00,1),(3,'Черновик',0.00,0);
UPDATE mysql106_sql_crud_products SET price=125.00 WHERE id=1;
DELETE FROM mysql106_sql_crud_products WHERE id=3 AND active=0;
SELECT id,name FROM mysql106_sql_crud_products
WHERE active=1 AND price BETWEEN 100.00 AND 200.00 AND id IN (1,2)
ORDER BY id;Как читается запрос и почему он решает задачу
CRUD — четыре обычных действия: Create (INSERT), Read (SELECT), Update (UPDATE), Delete (DELETE). CREATE TABLE относится к созданию структуры, а не к добавлению строки товара. Название CRUD полезно как карта операций, но сам запрос всегда должен выражать конкретное действие.
| Команда | Что изменится |
|---|---|
| INSERT INTO ... (поля) VALUES ... | Появятся три заданных товара. Явные имена полей связывают позицию каждого значения со схемой. |
| UPDATE ... SET price=125.00 WHERE id=1 | Найдётся товар 1; изменится только price. name и active остаются прежними. |
| DELETE ... WHERE id=3 AND active=0 | Удаляется строка 3 только пока она черновик. У опубликованного товара эта команда ничего не удалит. |
| BETWEEN 100.00 AND 200.00 | Обе границы включены: price ≥100 и ≤200. Это числовые DECIMAL, не строковая сортировка. |
| id IN (1,2) | ID принадлежит указанному набору. Это набор отдельных значений, не одна строка "1,2". |
| AND / OR и скобки | AND соединяет обязательные условия. Если добавляется альтернатива через OR, группируйте её явно: active=1 AND (id=1 OR id=2). |
Внутри PHP режим строгих ошибок превращает сбой SQL в исключение. affected_rows подтверждает одну изменённую и одну удалённую строку. Ноль изменений не всегда ошибка: данные могли уже совпадать или WHERE никого не нашёл; подробный пример есть в договоре результатов UPDATE.
Ошибки и границы
UPDATE и DELETE без WHERE затрагивают все подходящие строки таблицы; сначала полезно выполнить SELECT с тем же фильтром. Но один предварительный SELECT не защищает от конкурентного изменения: важное бизнес-условие оставляйте и в самой команде. Для истории заказов вместо физического DELETE часто нужен статус. Значения пользователя передавайте через параметры prepared statement, а не конкатенацию SQL.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_crud_products','id INT PRIMARY KEY, name VARCHAR(60) NOT NULL, price DECIMAL(8,2) NOT NULL, active TINYINT NOT NULL');
$db->query("INSERT INTO mysql106_sql_crud_products (id,name,price,active) VALUES (1,'Чай',120.00,1),(2,'Кофе',450.00,1),(3,'Черновик',0.00,0)");
$before=sql106Read($db,'SELECT id,name,price,active FROM mysql106_sql_crud_products ORDER BY id');
$db->query('UPDATE mysql106_sql_crud_products SET price=125.00 WHERE id=1');$updated=$db->affected_rows;
$db->query('DELETE FROM mysql106_sql_crud_products WHERE id=3 AND active=0');$deleted=$db->affected_rows;
$after=sql106Read($db,'SELECT id,name,price,active FROM mysql106_sql_crud_products ORDER BY id');
$selection=sql106Read($db,'SELECT id,name FROM mysql106_sql_crud_products WHERE active=1 AND price BETWEEN 100.00 AND 200.00 AND id IN (1,2) ORDER BY id');
$blocks=[sql106Block('До изменения',['id','name','price','active'],$before),sql106Block('После UPDATE и DELETE',['id','name','price','active'],$after),sql106Block('Подходящие под фильтр',['id','name'],$selection)];
$checks=['one_update'=>$updated===1,'one_delete'=>$deleted===1,'other_product_unchanged'=>$after[1]===$before[1],'filter_one_tea'=>$selection===[['1','Чай']]];
sql106Report('mysql106-sql-crud',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "crud: SQL results saved in result.html and result.json\n";
Ожидаемый результат
crud: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Попробуйте условие удаления id=2 AND active=0: удалённых строк будет 0, кофе останется. Для смены активности товара напишите отдельный UPDATE SET active=0 WHERE id=2; не меняйте смысл операции молча.
Сохранить клиентов без оплаченных заказов в отчёте
Менеджер хочет видеть всех клиентов и их оплаченные заказы, в том числе клиентов без оплат. Именно поэтому выбираем LEFT JOIN. Если экран предназначен только для оплативших, фильтр и тип соединения могут быть другими.
Исходные данные
| customers.id | name |
|---|---|
| 1 | Анна |
| 2 | Борис |
| 3 | Вера |
| orders.id | customer_id | status |
|---|---|---|
| 101 | 1 | paid |
| 102 | 1 | draft |
| 103 | 2 | draft |
Что получится
Статус в ON: все клиенты
| customer_id | name | order_id |
|---|---|---|
| 1 | Анна | 101 |
| 2 | Борис | NULL |
| 3 | Вера | NULL |
Статус в WHERE: только оплаченный заказ
| customer_id | name | order_id |
|---|---|---|
| 1 | Анна | 101 |
Имена полей в ассоциативной строке
| SELECT | Имена ключей | Значения |
|---|---|---|
| c.id,o.id | id | 101 |
| AS customer_id, AS order_id | customer_id, order_id | 1, 101 |
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
SELECT c.id AS customer_id,c.name,o.id AS order_id
FROM mysql106_sql_left_customers AS c
LEFT JOIN mysql106_sql_left_orders AS o
ON o.customer_id=c.id AND o.status='paid'
ORDER BY c.id,o.id;
-- Другой вопрос: оставить только клиентов с оплаченной строкой.
SELECT c.id AS customer_id,c.name,o.id AS order_id
FROM mysql106_sql_left_customers AS c
LEFT JOIN mysql106_sql_left_orders AS o ON o.customer_id=c.id
WHERE o.status='paid' ORDER BY c.id,o.id;Как читается запрос и почему он решает задачу
AS c и AS o — короткие псевдонимы таблиц. c.id означает колонку id именно клиента. ON o.customer_id=c.id определяет, какие строки соответствуют друг другу. LEFT JOIN сохраняет строку слева даже без пары, заполняя правые поля NULL. INNER JOIN (или просто JOIN) оставляет только пары.
- Анна: заказ 101 paid подходит к ON → есть номер заказа
- Борис: есть только draft → для paid-пары нет → NULL
- Вера: заказов вообще нет → NULL
- WHERE o.status=paid после соединения отбрасывает обе NULL-строки
Перенос условия статуса в ON означает «подбирай только оплаченные заказы, но клиентов сохраняй». WHERE после соединения означает «оставь только итоговые строки, у которых статус paid». Сравнение NULL с paid не TRUE, поэтому эти строки уходят. Это не косметическая перестановка. Реальный вопрос из Stack Overflow приведён ниже; здесь результат воспроизводится на наших трёх клиентах.
В этом примере FK специально не создан, чтобы было видно: SQL умеет соединять по равенству без внешнего ключа. Это не рекомендация отказаться от целостности рабочей базы. И наоборот, наличие FK не добавляет JOIN автоматически.
Два выбранных поля c.id,o.id без AS получили бы одинаковое имя id. При fetch_assoc последний столбец перекрывает предыдущий: остаётся 101 вместо пары 1 и 101. Реальная сравнительная таблица это показывает. AS customer_id и AS order_id делают оба ключа различимыми. Псевдоним меняет имя в результате, но не имя колонки в сохранённой таблице.
Ошибки и границы
LEFT JOIN не означает «одна строка на клиента»: если оплат два, клиент появится два раза. Если экрану нужен один итог на клиента, сгруппируйте/агрегируйте на сервере. Дописывание OR o.id IS NULL к WHERE не всегда эквивалентно переносу фильтра в ON: клиент с существующими, но неподходящими заказами уже не является строкой без пары.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_left_customers','id INT PRIMARY KEY, name VARCHAR(60) NOT NULL');
sql106Create($db,$created,'mysql106_sql_left_orders','id INT PRIMARY KEY, customer_id INT NOT NULL, status VARCHAR(12) NOT NULL');
$db->query("INSERT INTO mysql106_sql_left_customers VALUES (1,'Анна'),(2,'Борис'),(3,'Вера')");
$db->query("INSERT INTO mysql106_sql_left_orders VALUES (101,1,'paid'),(102,1,'draft'),(103,2,'draft')");
$base='SELECT c.id AS customer_id,c.name,o.id AS order_id FROM mysql106_sql_left_customers c LEFT JOIN mysql106_sql_left_orders o ON o.customer_id=c.id';
$on=sql106Read($db,$base." AND o.status='paid' ORDER BY c.id,o.id");
$where=sql106Read($db,$base." WHERE o.status='paid' ORDER BY c.id,o.id");
$duplicate=$db->query('SELECT c.id,o.id FROM mysql106_sql_left_customers c INNER JOIN mysql106_sql_left_orders o ON o.customer_id=c.id WHERE o.id=101')->fetch_assoc();
$alias=$db->query('SELECT c.id AS customer_id,o.id AS order_id FROM mysql106_sql_left_customers c INNER JOIN mysql106_sql_left_orders o ON o.customer_id=c.id WHERE o.id=101')->fetch_assoc();
$blocks=[sql106Block('Статус в ON: все клиенты',['customer_id','name','order_id'],$on),sql106Block('Статус в WHERE: только оплаченный заказ',['customer_id','name','order_id'],$where),sql106Block('Имена полей в ассоциативной строке',['SELECT','Имена ключей','Значения'],[['c.id,o.id',implode(', ',array_keys($duplicate)),implode(', ',array_values($duplicate))],['AS customer_id, AS order_id',implode(', ',array_keys($alias)),implode(', ',array_values($alias))]])];
$checks=['all_customers_on'=>count($on)===3,'where_removed_nulls'=>count($where)===1,'no_order_is_null'=>$on[2][2]===null,'duplicate_name_loses_first'=>count($duplicate)===1 && (int)$duplicate['id']===101,'aliases_preserve_both'=>count($alias)===2 && (int)$alias['customer_id']===1];
sql106Report('mysql106-sql-left-join',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "left-join: SQL results saved in result.html and result.json\n";
Ожидаемый результат
left-join: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Добавьте Вере оплаченный заказ 104. В результате с фильтром ON останутся три клиента, у Веры появится 104. Если у Анны появится второй paid-заказ, строк будет четыре: это полезный способ увидеть кардинальность соединения.
Ситуация из практического обсуждения: Why does adding certain conditions to a left join turn it into an inner join?. Условие по правой таблице в WHERE неожиданно убирает строки без подходящей пары. Наши Анна, Борис и Вера показывают, чем отличаются фильтры ON и WHERE, включая существующий, но неподходящий заказ. Обсуждение помогает выбрать задачу; правила MySQL проверены по первичным источникам, код и данные написаны для этой главы.
Собрать карточку заказа из связанных таблиц одним JOIN
Пользователь открыл заказ 101. Экрану нужны имя клиента, названия купленных товаров, количества и исторические цены. Не нужно читать весь каталог, всех клиентов и все заказы, а затем искать связи циклами в PHP.
Исходные данные
| customers.id | name |
|---|---|
| 1 | Анна |
| 2 | Борис |
| products.id | name | Текущая price |
|---|---|---|
| 10 | Чай | 150.00 |
| 20 | Кофе | 500.00 |
| orders.id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 2 |
| order_items.order_id | product_id | qty | unit_price |
|---|---|---|---|
| 101 | 10 | 2 | 120.00 |
| 101 | 20 | 1 | 450.00 |
| 102 | 10 | 1 | 150.00 |
Что получится
Карточка заказа 101
| order_id | customer | product | qty | unit_price | line_total |
|---|---|---|---|---|---|
| 101 | Анна | Чай | 2 | 120.00 | 240.00 |
| 101 | Анна | Кофе | 1 | 450.00 | 450.00 |
Итого заказа
| total |
|---|
| 690.00 |
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
SELECT o.id AS order_id,c.name AS customer,p.name AS product,
i.qty,i.unit_price,i.qty*i.unit_price AS line_total
FROM mysql106_sql_card_orders AS o
JOIN mysql106_sql_card_customers AS c ON c.id=o.customer_id
JOIN mysql106_sql_card_items AS i ON i.order_id=o.id
JOIN mysql106_sql_card_products AS p ON p.id=i.product_id
WHERE o.id=101
ORDER BY i.product_id;Как читается запрос и почему он решает задачу
- orders o: WHERE o.id=101 выбирает нужный заказ
- customers c: c.id=o.customer_id даёт Анну
- order_items i: i.order_id=o.id даёт две позиции
- products p: p.id=i.product_id даёт названия Чай и Кофе
- SELECT: имена, qty, историческая unit_price и вычисленная line_total
Каждый ON формулирует отдельную связь. JOIN здесь внутренний: пример гарантирует существование родителей FK и хранит две позиции. Заказ без позиций не появится в таком наборе строк; для его отдельного заголовка можно сделать второй маленький запрос или LEFT JOIN с явным учётом NULL. Не выбирайте LEFT автоматически, не решив, что должен показывать экран.
SELECT перечисляет именно колонки карточки; price из products не выбирается, потому что это цена сегодня, а не цена покупки. Умножение i.qty*i.unit_price выполняется сервером и получает имя line_total. Для заказа 101 строки 240.00 и 450.00 дают сумму 690.00. Второй узкий SELECT SUM по order_id возвращает одну сумму; мы не загружаем чужие позиции для подсчёта.
Несколько одинаковых customer в строках результата — нормальная плоская форма JOIN. Это не дублирование в хранилище: имя клиента хранится один раз. PHP может оформить заголовок и повторяющиеся строки HTML. N+1 (отдельный запрос названия для каждой позиции) здесь не нужен: необходимые названия уже получены одним соединением.
Ошибки и границы
WHERE o.id=101 в учебном запросе — константа, но реальный номер заказа приходит из запроса пользователя и передаётся параметром. Дополнительно проверьте право текущего пользователя видеть этот заказ; правильный JOIN сам по себе не авторизация. Не соединяйте независимые списки позиций и оплат без понимания умножения строк.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_card_customers','id INT PRIMARY KEY, name VARCHAR(60) NOT NULL');
sql106Create($db,$created,'mysql106_sql_card_products','id INT PRIMARY KEY, name VARCHAR(60) NOT NULL, price DECIMAL(8,2) NOT NULL');
sql106Create($db,$created,'mysql106_sql_card_orders','id INT PRIMARY KEY, customer_id INT NOT NULL, FOREIGN KEY(customer_id) REFERENCES mysql106_sql_card_customers(id)');
sql106Create($db,$created,'mysql106_sql_card_items','order_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL, unit_price DECIMAL(8,2) NOT NULL, PRIMARY KEY(order_id,product_id), FOREIGN KEY(order_id) REFERENCES mysql106_sql_card_orders(id), FOREIGN KEY(product_id) REFERENCES mysql106_sql_card_products(id)');
$db->query("INSERT INTO mysql106_sql_card_customers VALUES (1,'Анна'),(2,'Борис')");
$db->query("INSERT INTO mysql106_sql_card_products VALUES (10,'Чай',150.00),(20,'Кофе',500.00)");
$db->query('INSERT INTO mysql106_sql_card_orders VALUES (101,1),(102,2)');
$db->query('INSERT INTO mysql106_sql_card_items VALUES (101,10,2,120.00),(101,20,1,450.00),(102,10,1,150.00)');
$query='SELECT o.id AS order_id,c.name AS customer,p.name AS product,i.qty,i.unit_price,i.qty*i.unit_price AS line_total FROM mysql106_sql_card_orders o JOIN mysql106_sql_card_customers c ON c.id=o.customer_id JOIN mysql106_sql_card_items i ON i.order_id=o.id JOIN mysql106_sql_card_products p ON p.id=i.product_id WHERE o.id=101 ORDER BY i.product_id';
$rows=sql106Read($db,$query);
$total=sql106Read($db,'SELECT SUM(qty*unit_price) AS total FROM mysql106_sql_card_items WHERE order_id=101');
$blocks=[sql106Block('Карточка заказа 101',['order_id','customer','product','qty','unit_price','line_total'],$rows),sql106Block('Итого заказа',['total'],$total)];
$checks=['only_requested_order'=>count($rows)===2 && $rows[0][0]==='101' && $rows[1][0]==='101','historical_not_current_price'=>$rows[0][4]==='120.00','order_total'=>$total===[['690.00']]];
sql106Report('mysql106-sql-order-card',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "order-card: SQL results saved in result.html and result.json\n";
Ожидаемый результат
order-card: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Сформулируйте экран истории клиента 1: нужны номера заказов этого клиента и суммы, но не весь список товаров. Начните с orders WHERE customer_id=1, агрегируйте позиции по order_id; используйте следующий пример, чтобы не умножить итог на количество строк.
Посчитать сводку и не размножить сумму JOIN-ом
В сводке нужны покупатели, оплатившие не менее 700. Анна оплатила два заказа на 690 и 120, поэтому должна получить одну строку с суммой 810. Заодно показываем частую ошибку: JOIN позиций превращает правильную сумму в 1500.
Исходные данные
| orders.id | customer_id | status | total |
|---|---|---|---|
| 101 | 1 | paid | 690.00 |
| 102 | 1 | paid | 120.00 |
| 103 | 2 | draft | 450.00 |
| order_items.order_id | product_id | qty | unit_price |
|---|---|---|---|
| 101 | 10 | 2 | 120.00 |
| 101 | 20 | 1 | 450.00 |
| 102 | 10 | 1 | 120.00 |
| 103 | 20 | 1 | 450.00 |
Что получится
Покупатели с оплаченными заказами от 700
| customer_id | orders | total |
|---|---|---|
| 1 | 2 | 810.00 |
Одна сумма заказа на каждую позицию — ошибка
| Способ | total |
|---|---|
| SUM(o.total) после JOIN позиций | 1500.00 |
| SUM(total) из orders | 810.00 |
Сначала одна сумма на заказ, потом JOIN
| order_id | total |
|---|---|
| 101 | 690.00 |
| 102 | 120.00 |
SQL: смысловая часть примера
Фрагмент выполняется в контексте таблиц этого самостоятельного примера; полная подготовка входа находится в архиве.
SELECT customer_id,COUNT(*) AS orders,SUM(total) AS total
FROM mysql106_sql_group_orders
WHERE status='paid'
GROUP BY customer_id
HAVING SUM(total)>=700
ORDER BY total DESC,customer_id;
-- Для соединения позиций с заказами сначала получаем одну строку на заказ:
SELECT order_id,SUM(qty*unit_price) AS total
FROM mysql106_sql_group_items GROUP BY order_id;Как читается запрос и почему он решает задачу
| Операция | Роль в этом запросе |
|---|---|
| WHERE status=paid | Сначала оставляем оплаченные заказы, исключая черновик Бориса. |
| GROUP BY customer_id | Собираем строки одного покупателя в группу. Результат становится сводкой, а не списком отдельных заказов. |
| COUNT(*) | Считаем строки заказов в группе: у Анны 2. COUNT(поле) считает значения, отличные от NULL; 0 и пустая строка учитываются. После LEFT JOIN это различие особенно важно. |
| SUM(total) | Суммируем total оплаченных заказов: 690+120=810. AVG, MIN, MAX аналогично дают среднее, минимум и максимум по значениям. |
| HAVING SUM(total)>=700 | Фильтруем уже группы по вычисленной сумме. WHERE относится к строкам до группировки; в нём нельзя так фильтровать агрегат этой группы. |
| ORDER BY total DESC,customer_id | Сортируем сводку по сумме, затем по ключу покупателя. AS total задаёт имя результата SUM. |
Полный пример делает и заведомо неверный запрос SUM(o.total) после JOIN позиций. Заказ 101 имеет две позиции, поэтому его total=690 повторился дважды: 690+690+120=1500. SQL исполнился успешно, но вопрос к данным был сформулирован неверно. Для суммы заказов достаточно таблицы orders. Если нужны итоги из самих позиций, сначала GROUP BY order_id даёт одну строку на заказ, и только потом её соединяют с orders.
Не «исправляйте» эту ошибку SUM(DISTINCT total): два разных заказа могут иметь одинаковую сумму, и тогда один будет потерян. Нужно выбрать правильный уровень строк. В этой демонстрационной схеме orders.total уже задан как сохранённый итог; если одновременно храните строки и итог в рабочем сервисе, согласованность поддерживайте одной транзакцией и понятным правилом пересчёта.
При ONLY_FULL_GROUP_BY нельзя произвольно выбрать название одной строки рядом с SUM без группировки или подходящей функциональной зависимости. Не отключайте режим ради исчезновения ошибки: определите, какое значение действительно нужно. Наш запрос выбирает ключ группы и агрегаты и не зависит от случайной строки.
Ошибки и границы
После LEFT JOIN COUNT(*) посчитает и строку клиента без заказа, тогда как COUNT(o.id) даст 0. SUM по отсутствующим значениям может вернуть NULL; если для конкретного отчёта нужен 0, задайте это явно через COALESCE(SUM(...),0). Агрегат не заменяет проверку валюты и единиц: нельзя бездумно суммировать разные валюты в один total.
Полная самостоятельная PHP-программа и скачивание архива
<?php
declare(strict_types=1);
require __DIR__.'/sql106-helper.php';
$db=sql106Connect(); $created=[]; $blocks=[]; $checks=[]; $diagnostics=[];
try {
sql106Create($db,$created,'mysql106_sql_group_orders','id INT PRIMARY KEY, customer_id INT NOT NULL, status VARCHAR(12) NOT NULL, total DECIMAL(8,2) NOT NULL');
sql106Create($db,$created,'mysql106_sql_group_items','order_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL, unit_price DECIMAL(8,2) NOT NULL, PRIMARY KEY(order_id,product_id)');
$db->query("INSERT INTO mysql106_sql_group_orders VALUES (101,1,'paid',690.00),(102,1,'paid',120.00),(103,2,'draft',450.00)");
$db->query('INSERT INTO mysql106_sql_group_items VALUES (101,10,2,120.00),(101,20,1,450.00),(102,10,1,120.00),(103,20,1,450.00)');
$summary=sql106Read($db,"SELECT customer_id,COUNT(*) AS orders,SUM(total) AS total FROM mysql106_sql_group_orders WHERE status='paid' GROUP BY customer_id HAVING SUM(total)>=700 ORDER BY total DESC,customer_id");
$bad=sql106Read($db,"SELECT SUM(o.total) FROM mysql106_sql_group_orders o JOIN mysql106_sql_group_items i ON i.order_id=o.id WHERE o.status='paid'");
$correct=sql106Read($db,"SELECT SUM(total) FROM mysql106_sql_group_orders WHERE status='paid'");
$preaggregate=sql106Read($db,"SELECT o.id,t.total FROM mysql106_sql_group_orders o JOIN (SELECT order_id,SUM(qty*unit_price) AS total FROM mysql106_sql_group_items GROUP BY order_id) t ON t.order_id=o.id WHERE o.status='paid' ORDER BY o.id");
$blocks=[sql106Block('Покупатели с оплаченными заказами от 700',['customer_id','orders','total'],$summary),sql106Block('Одна сумма заказа на каждую позицию — ошибка',['Способ','total'],[['SUM(o.total) после JOIN позиций',$bad[0][0]],['SUM(total) из orders',$correct[0][0]]]),sql106Block('Сначала одна сумма на заказ, потом JOIN',['order_id','total'],$preaggregate)];
$checks=['summary_two_orders_810'=>$summary===[['1','2','810.00']],'fanout_visible'=>$bad===[['1500.00']],'correct_sum_810'=>$correct===[['810.00']],'preaggregate_two_orders'=>$preaggregate===[['101','690.00'],['102','120.00']]];
sql106Report('mysql106-sql-grouping',$blocks,$checks,$db,$diagnostics);
} finally {
sql106Cleanup($db,$created);
}
echo "grouping: SQL results saved in result.html and result.json\n";
Ожидаемый результат
grouping: SQL results saved in result.html and result.json
Соединение и HTML-отчёт вынесены в приложенный sql106-helper.php. Все исходные данные создаёт этот код. После запуска откройте result.html; result.json содержит те же таблицы. Ожидаемый stdout — только краткое уведомление, полезный результат показан таблицами выше.
Открыть сохранённый результат реального запуска · Скачать таблицы JSON.
Попробуйте сами
Опустите HAVING-порог до 100: состав оплаченных клиентов останется тем же, потому что заказ Бориса всё ещё draft. Затем поменяйте его статус на paid: появится отдельная группа customer_id=2 с одной строкой и суммой 450.00. Не переносите HAVING в PHP-фильтрацию уже загруженной большой сводки.
Когда нужен поток, а когда страница
Для экрана начните с ограниченной серверной выборки. Для полного экспорта все строки действительно могут понадобиться — тогда нужен потоковый обход, CSV и контролируемое потребление памяти. Это другая задача, разобранная в жизненном цикле результата. Показатели клиента, ограничения измерений и mysqlnd рассмотрены в наблюдении реальных запросов. Не путайте уменьшение выдачи с настройкой буфера уже полученного огромного результата.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


