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

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

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

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

Глава 45 · После таблиц и связей

MySQL: получить нужный экран, а не всю базу в PHP

SELECT * и явные поля, 100000 строк против страницы из 20, JOIN и алиасы, CRUD, GROUP BY/HAVING, реальные планы EXPLAIN.

Сначала экран, затем запрос

Принцип этой главы: запрос должен вернуть данные, которые нужны сейчас на экране. Каталогу — страницу товаров, карточке заказа — его позиции, истории клиента — его заказы, сводке — агрегаты. Не получаем всю базу в PHP, чтобы затем искать нужные строки, сортировать большой массив и выбрасывать почти всё.

ЭкранИсточники и связиФорма ответа
Каталог категорииproducts; JOIN не нужен, если нужные данные уже в этой таблицеid,name,price; WHERE; ORDER BY; LIMIT 20
Карточка заказа 101orders → customers; orders → order_items → productsТолько позиции заказа 101 с количеством и ценой покупки
История клиента 1orders WHERE customer_id=1, сумма позиций по order_idНомера и итоги заказов, ограниченная страница
Сводка продажОплаченные orders, GROUP BY customer_idОдна строка с числом заказов и суммой на клиента

SELECT определяет поля ответа; FROM и JOIN — откуда они берутся и как связаны; WHERE — какие строки подходят; GROUP BY/HAVING — нужна ли сводка; ORDER BY — порядок; LIMIT — объём страницы. Это логическая модель смысла, не обещание физического порядка работы оптимизатора. Связи и ключи описаны в предыдущей SQL-главе.

Один запрос — один понятный результат
  1. Назовите экран и его поля
  2. Определите нужные таблицы и кардинальность связей
  3. Ограничьте строки и столбцы на сервере
  4. Сделайте порядок устойчивым, задайте границу страницы
  5. Проверьте реальный результат и EXPLAIN
  6. PHP читает и оформляет уже нужные строки

SELECT * и явные поля: что действительно получает PHP

Карточке каталога нужны название и цена. В таблице дополнительно лежат длинное описание и служебная заметка. Сравним, что реально приходит в PHP при SELECT * и при SELECT name,price, затем добавим новую колонку и посмотрим, какой договор изменится.

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

ПолеВходное значение
id1
nameЧай
price120.00
descriptionСтрока «Long product description. » повторена 100 раз (2600 ASCII-символов)
internal_notedraft supplier note

На экран должны попасть только Чай и 120.00. Длинный текст создаёт сервер через REPEAT; вход воспроизводим без скачивания чужих данных.

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

Данные для карточки

nameprice
Чай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-программа и скачивание архива
SELECT * и явные поля: что действительно получает 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.

Сохранённый результат реального запуска

Данные для карточки

nameprice
Чай120.00

Форма результата

ЗапросПоля до ALTERПоля после ALTERJSON UTF-8, байт до ALTER
SELECT *id, name, price, description, internal_noteid, name, price, description, internal_note, supplier_code2698
SELECT name,pricename, pricename, price34

Попробуйте сами

Для карточки товара разрешите 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 здесь строит все сочетания пяти цифр; это генератор учебных данных, а не способ запроса каталога.

idcategory_idactiveprice_centsname
110100Product 1
770106Product 7
2771126Product 27
4771146Product 47

Это несколько строк для понимания формулы; полная таблица действительно создаётся на сервере, но её 100 000 строк не публикуются как огромный JSON. Результат ниже — точная первая страница из этого набора.

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

Страница каталога: ровно 20 строк

idnameprice_cents
37887Product 37887100
57827Product 57827100
97707Product 97707100
24927Product 24927101
44867Product 44867101
84747Product 84747101
11967Product 11967102
31907Product 31907102
71787Product 71787102
91727Product 91727102
18947Product 18947103
58827Product 58827103
78767Product 78767103
5987Product 5987104
45867Product 45867104
65807Product 65807104
32907Product 32907105
52847Product 52847105
92727Product 92727105
19947Product 19947106

Что получил PHP

СпособПолучено строкПоказано строк
Вся таблица → фильтр и sort в PHP10000020
WHERE → ORDER BY → LIMIT в MySQL2020

Начало следующей страницы по курсору

idnameprice_cents
39887Product 39887106
79767Product 79767106
99707Product 99707106

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;

Как читается запрос и почему он решает задачу

Проектируем запрос от экрана
  1. Экран: 20 активных товаров категории 7
  2. FROM: одна таблица products; JOIN здесь не нужен
  3. SELECT: id, name, price_cents — только поля карточек списка
  4. WHERE: category_id=7 AND active=1
  5. ORDER BY: price_cents, затем id для равных цен
  6. 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-программа и скачивание архива
Из 100 000 товаров получить ровно страницу каталогаСамостоятельный пример
<?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 строк

idnameprice_cents
37887Product 37887100
57827Product 57827100
97707Product 97707100
24927Product 24927101
44867Product 44867101
84747Product 84747101
11967Product 11967102
31907Product 31907102
71787Product 71787102
91727Product 91727102
18947Product 18947103
58827Product 58827103
78767Product 78767103
5987Product 5987104
45867Product 45867104
65807Product 65807104
32907Product 32907105
52847Product 52847105
92727Product 92727105
19947Product 19947106

Что получил PHP

СпособПолучено строкПоказано строк
Вся таблица → фильтр и sort в PHP10000020
WHERE → ORDER BY → LIMIT в MySQL2020

Начало следующей страницы по курсору

idnameprice_cents
39887Product 39887106
79767Product 79767106
99707Product 99707106

Попробуйте сами

Измените экран: категория 12, активные товары, сначала дорогие. Нужны WHERE category_id=12, ORDER BY price_cents DESC,id DESC и соответствующий знак курсора <. Проверьте запрос и индекс через EXPLAIN. Не загружайте все товары в PHP для сортировки; PHP остаются HTML-вывод и форматирование уже выбранных строк.

Изменить один товар, затем удалить только учебный черновик

Редактор каталога повышает цену одного товара и удаляет один неопубликованный черновик. Другие товары должны остаться без изменений. Затем фильтр показывает только активные позиции нужного ценового диапазона.

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

idnamepriceactive
1Чай120.001
2Кофе450.001
3Черновик0.000

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

До изменения

idnamepriceactive
1Чай120.001
2Кофе450.001
3Черновик0.000

После UPDATE и DELETE

idnamepriceactive
1Чай125.001
2Кофе450.001

Подходящие под фильтр

idname
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.idname
1Анна
2Борис
3Вера
orders.idcustomer_idstatus
1011paid
1021draft
1032draft

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

Статус в ON: все клиенты

customer_idnameorder_id
1Анна101
2БорисNULL
3ВераNULL

Статус в WHERE: только оплаченный заказ

customer_idnameorder_id
1Анна101

Имена полей в ассоциативной строке

SELECTИмена ключейЗначения
c.id,o.idid101
AS customer_id, AS order_idcustomer_id, order_id1, 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) оставляет только пары.

Почему положение условия меняет ответ
  1. Анна: заказ 101 paid подходит к ON → есть номер заказа
  2. Борис: есть только draft → для paid-пары нет → NULL
  3. Вера: заказов вообще нет → NULL
  4. 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.idname
1Анна
2Борис
products.idnameТекущая price
10Чай150.00
20Кофе500.00
orders.idcustomer_id
1011
1022
order_items.order_idproduct_idqtyunit_price
101102120.00
101201450.00
102101150.00

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

Карточка заказа 101

order_idcustomerproductqtyunit_priceline_total
101АннаЧай2120.00240.00
101АннаКофе1450.00450.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;

Как читается запрос и почему он решает задачу

Карточка заказа 101 → только нужные связи
  1. orders o: WHERE o.id=101 выбирает нужный заказ
  2. customers c: c.id=o.customer_id даёт Анну
  3. order_items i: i.order_id=o.id даёт две позиции
  4. products p: p.id=i.product_id даёт названия Чай и Кофе
  5. 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-программа и скачивание архива
Собрать карточку заказа из связанных таблиц одним JOINСамостоятельный пример
<?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.idcustomer_idstatustotal
1011paid690.00
1021paid120.00
1032draft450.00
order_items.order_idproduct_idqtyunit_price
101102120.00
101201450.00
102101120.00
103201450.00

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

Покупатели с оплаченными заказами от 700

customer_idorderstotal
12810.00

Одна сумма заказа на каждую позицию — ошибка

Способtotal
SUM(o.total) после JOIN позиций1500.00
SUM(total) из orders810.00

Сначала одна сумма на заказ, потом JOIN

order_idtotal
101690.00
102120.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-программа и скачивание архива
Посчитать сводку и не размножить сумму JOIN-омСамостоятельный пример
<?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. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.