Все материалы
Продуктовая аналитикагайдстарт

Порядок выполнения SQL-запроса: от FROM до LIMIT

Логический порядок выполнения SQL-запроса по шагам: FROM, WHERE, GROUP BY, HAVING, оконные функции, SELECT, DISTINCT, ORDER BY, LIMIT. Почему алиас не виден в WHERE.

КейсПрактика13 октября 2026 г.18 мин

Вы считаете годовую стоимость подписки: amount * 12 as yearly. Строкой ниже хотите оставить платежи дороже 400 и пишете where yearly > 400. PostgreSQL отвечает, что столбца yearly не существует, хотя вы назвали его секунду назад. При этом order by yearly в том же запросе работает. Объяснение одно: база выполняет части запроса не в том порядке, в каком они записаны. SELECT стоит первым в тексте, но выполняется почти в конце, когда WHERE уже отработал. Из того же порядка следует, почему агрегат нельзя сравнить в WHERE, почему оконная функция не фильтруется без подзапроса и почему LIMIT не помогает посчитать «среднее по последним десяти». Ниже порядок выполнения SQL-запроса разобран по шагам на учебной базе Симулятора SQL-аналитика: 4613 пользователей и 1251 платёж.

Коротко

Логический порядок выполнения SELECT-запроса: FROM и JOIN → WHERE → GROUP BY → HAVING → оконные функции → SELECT → DISTINCT → ORDER BY → LIMIT. Каждый шаг получает на вход то, что оставил предыдущий, и не знает о том, что появится позже.

  • WHERE работает до SELECT, поэтому алиас из SELECT в нём не виден. В ORDER BY алиас виден: сортировка идёт после SELECT.
  • WHERE фильтрует строки до группировки, HAVING — готовые группы. Условие с count(*) или sum(...) возможно только в HAVING.
  • Оконные функции считаются после HAVING по тем строкам, что остались. Отфильтровать по их результату можно только во внешнем запросе.
  • DISTINCT убирает дубли среди готовых строк результата, а не в исходной таблице.
  • LIMIT срабатывает последним. Чтобы посчитать что-то по первым N строкам, их сначала отбирают в подзапросе.
  • Это логический порядок: он определяет результат и видимость имён. В каком порядке база читает таблицы на самом деле, решает оптимизатор.

Порядок записи и порядок выполнения

Слова в запросе идут в фиксированном порядке: SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. Поменять их местами нельзя, база вернёт синтаксическую ошибку. Но читает она запрос иначе: сначала выясняет, откуда брать строки, потом — какие из них оставить, и только после этого — что показать.

Запрос ниже отвечает на вопрос «какие два канала привлечения принесли больше всего денег в августе, если не считать каналы с десятком-другим платежей». В нём есть все основные части, и по таблице видно, что происходит со строками на каждом шаге.

На первом шаге платежи соединяются с пользователями: 1251 строка, по одной на платёж. WHERE оставляет 642 августовских. GROUP BY складывает их в четыре группы по каналу, и для каждой считаются count(*) и sum(amount). HAVING убирает paid_search с 71 платежом, остаются три канала. SELECT формирует три столбца и даёт им имена. ORDER BY сортирует по выручке, LIMIT отдаёт две верхние строки.

Обратите внимание на последние две строки запроса. В HAVING агрегат написан целиком, count(*) >= 80, а в ORDER BY стоит алиас revenue. Это не дело вкуса: на шаге HAVING имени payment_cnt ещё нет, на шаге ORDER BY имя revenue уже есть.

Логический порядок выполнения SELECT-запроса и что остаётся после каждого шага в примере
ШагЧасть запросаЧто делаетЧто уже доступноВ примере
1FROM, JOINсобирает строки из таблицстолбцы таблиц1251 строка
2WHEREотбрасывает отдельные строкистолбцы таблиц642 строки
3GROUP BYскладывает строки в группы, считает агрегатыстолбцы таблиц4 группы
4HAVINGотбрасывает целые группыключи групп и агрегаты3 группы
5оконные функциисчитают по оставшимся строкамключи групп и агрегатынет
6SELECTвычисляет выражения и раздаёт алиасывсё перечисленное3 столбца
7DISTINCTубирает одинаковые строки результатастроки результатанет
8ORDER BYсортирует результатстолбцы результата и алиасы3 строки по убыванию выручки
9LIMIT, OFFSETобрезает результатготовый отсортированный результат2 строки
Все основные части в одном запросе: два канала с наибольшей выручкой в августе
select u.channel,
       count(*) as payment_cnt,
       sum(p.amount) as revenue
from payments as p
join users as u on u.user_id = p.user_id
where p.paid_at >= date '2026-08-01'
group by u.channel
having count(*) >= 80
order by revenue desc
limit 2;

-- organic  | 283 | 7087.00
-- referral | 198 | 4792.00

FROM и JOIN: сначала база собирает строки

Запрос начинается с FROM. База берёт таблицу, присоединяет к ней остальные по условиям из ON и получает один широкий набор строк. Все следующие шаги работают уже с ним, а не с исходными таблицами.

Отсюда два практических следствия. Первое: короткие имена таблиц, заданные в FROM (payments as p), известны всем остальным частям запроса, включая SELECT, который записан выше. Поэтому u.channel в первой строке запроса не вызывает ошибки, хотя u объявлено тремя строками ниже.

Второе: число строк определяется здесь, до любых фильтров и агрегатов. Если у пользователя три платежа, после соединения у него три строки, и count(*) посчитает платежи, а не людей. Когда итоговое число кажется завышенным, проверять нужно первый шаг: сколько строк вышло из FROM и JOIN. Про это есть отдельный разбор — JOIN без потерь и дублей.

Условие в ON относится к этому же шагу, а WHERE — к следующему. Для обычного JOIN разницы в результате нет. Для LEFT JOIN она есть: условие в ON решает, какие строки присоединять, а условие в WHERE выбрасывает строки уже после соединения, в том числе те, что остались без пары. Пример с числами — в статье про LEFT JOIN.

Почему алиас не работает в WHERE

WHERE — второй шаг. К этому моменту у базы есть только столбцы таблиц из FROM. Выражения из SELECT ещё не вычислены, имён у них нет. Поэтому запрос из начала статьи PostgreSQL отклоняет, даже не начав выполнять.

Тот же запрос в DuckDB выполняется и возвращает три платежа по тарифу team. DuckDB сам подставляет вместо алиаса выражение, которое за ним стоит. На DuckDB работает песочница симулятора и проверка в курсах, так что запрос с алиасом в WHERE там пройдёт, а на рабочей базе с PostgreSQL упадёт. Привыкать к этой вольности не стоит.

Переносимых способов два. Короткое выражение проще повторить: where amount * 12 > 400. Таких платежей 142, и запрос работает в любой базе. Длинное выражение удобнее вычислить в подзапросе или CTE: во внешнем запросе алиас становится обычным столбцом, и WHERE его видит. Как устроен такой запрос, показано в статье про CTE, а остальные правила фильтра — в статье про WHERE.

По той же причине в WHERE нельзя написать count(*) > 100. Агрегаты появляются на шаге GROUP BY, а он ещё не наступил.

DuckDB принимает алиас из SELECT в WHERE; в PostgreSQL этот запрос не выполнится
select payment_id, plan, amount * 12 as yearly
from payments
where yearly > 400
order by payment_id
limit 3;

-- 4 | team | 468.00
-- 24 | team | 468.00
-- 35 | team | 468.00
codeОтвет PostgreSQL 14 на тот же запрос
ERROR:  column "yearly" does not exist
LINE 3: where yearly > 400
              ^

WHERE или HAVING: фильтр до группировки и после

WHERE и HAVING делают похожую работу на разных шагах. WHERE решает судьбу каждой строки до того, как строки сложатся в группы. HAVING смотрит на готовую группу и решает, оставить её целиком или убрать. Отсюда простое правило выбора: условие про строку — в WHERE, условие про группу — в HAVING.

Важнее другое: WHERE меняет сами числа, а HAVING — только список групп. Без фильтров платежи распределены по каналам так: organic 566, referral 384, partner 198, paid_search 103. Добавим where p.plan = 'team' — и счётчики становятся другими: 66, 45, 22 и 9, потому что в группы попали только платежи по тарифу team. Теперь добавим having count(*) >= 30. Числа не изменились, но из четырёх строк остались две.

Условие по ключу группировки формально можно написать в обоих местах. having u.channel <> 'organic' и where u.channel <> 'organic' возвращают одни и те же три строки: 384, 198 и 103. Пишут такое условие в WHERE: так запрос читается честнее, и база не тратит время на группу, которую потом выбросит.

Обратное не работает. Условие на агрегат в WHERE — ошибка в любой базе. Подробный разбор с примерами — в статье HAVING в SQL, а сама группировка — в статье про GROUP BY и COUNT.

WHERE оставляет платежи по тарифу team, HAVING — каналы, где таких платежей не меньше 30
select u.channel,
       count(*) as payment_cnt,
       sum(p.amount) as revenue
from payments as p
join users as u on u.user_id = p.user_id
where p.plan = 'team'
group by u.channel
having count(*) >= 30
order by payment_cnt desc;

-- organic  | 66 | 2574.00
-- referral | 45 | 1755.00

GROUP BY и алиас: работает, но с оговоркой

GROUP BY выполняется раньше SELECT, так что по логике алиас в нём тоже не должен быть виден. Стандарт SQL так и считает. Но PostgreSQL и DuckDB делают исключение: в GROUP BY можно написать имя столбца результата или его номер (group by 1). Первый запрос ниже считает регистрации по месяцам и работает в обоих движках: 1042 в июне, 1676 в июле, 1895 в августе.

Оговорка касается имён. Если алиас совпал с именем столбца таблицы, GROUP BY выберет столбец таблицы: исходные данные для него важнее. Во втором запросе выражение с началом месяца названо signup_date — так же, как столбец в users. Группировка идёт по исходной дате, то есть по дням. В результате 90 строк вместо трёх, и в каждой стоит первое число месяца. Ошибки нет, запрос выполнился, просто отчёт по месяцам превратился в отчёт по дням с неверными подписями.

ORDER BY в том же запросе ведёт себя наоборот: он выполняется после SELECT и одноимённый алиас предпочитает столбцу таблицы. Поэтому строки отсортированы по первому числу месяца, а внутри него — по счётчику.

Защита простая: давайте вычисленному столбцу имя, которого нет в таблицах. signup_month вместо signup_date, payment_cnt вместо payments. А в HAVING алиас в PostgreSQL не работает вовсе — там агрегат пишут целиком.

Алиас в GROUP BY: регистрации по месяцам
select cast(date_trunc('month', signup_date) as date) as signup_month,
       count(*) as user_cnt
from users
group by signup_month
order by signup_month;

-- 2026-06-01 | 1042
-- 2026-07-01 | 1676
-- 2026-08-01 | 1895
sqlАлиас совпал с именем столбца: группировка идёт по дням, в результате 90 строк
select cast(date_trunc('month', signup_date) as date) as signup_date,
       count(*) as user_cnt
from users
group by signup_date
order by signup_date, user_cnt
limit 4;

-- 2026-06-01 | 14
-- 2026-06-01 | 15
-- 2026-06-01 | 16
-- 2026-06-01 | 17

Когда выполняются оконные функции

Оконные функции — row_number(), sum(...) over (...), lag() — считаются после WHERE, GROUP BY и HAVING, но до DISTINCT, ORDER BY и LIMIT. Окно видит только те строки, которые дошли до этого шага. Если в запросе есть группировка, строками для окна становятся группы.

На этом построен удобный приём: sum(count(*)) over (). Внутренний count(*) — обычный агрегат, он посчитан на шаге GROUP BY. Внешний sum(...) over () складывает эти счётчики по всем группам. Делим одно на другое и получаем долю канала в общем числе платежей: 45,2% у organic, 30,7% у referral, 15,8% у partner и 8,2% у paid_search.

Теперь добавим в этот запрос having count(*) >= 150, чтобы скрыть мелкий канал. Строка paid_search исчезла, как и задумано, но доли остальных выросли: 49,3%, 33,4% и 17,2%. HAVING отработал раньше окна, и окно сложило только три оставшихся счётчика. Сумма долей снова 100%, и по таблице не видно, что из знаменателя пропали 103 платежа.

Если доля должна считаться от всех платежей, порядок шагов нужно задать самому: сначала посчитать доли в CTE, потом отфильтровать готовые строки во внешнем запросе. Больше примеров — в статьях про долю и процент в SQL и про оконные функции.

HAVING срабатывает раньше окна: доли посчитаны от трёх каналов, а не от четырёх
select u.channel,
       count(*) as payment_cnt,
       round(count(*) * 100.0 / sum(count(*)) over (), 1) as share_pct
from payments as p
join users as u on u.user_id = p.user_id
group by u.channel
having count(*) >= 150
order by payment_cnt desc;

-- organic  | 566 | 49.3
-- referral | 384 | 33.4
-- partner  | 198 | 17.2
sqlДоли посчитаны в CTE по всем каналам, фильтр применён после
with shares as (
  select u.channel,
         count(*) as payment_cnt,
         round(count(*) * 100.0 / sum(count(*)) over (), 1) as share_pct
  from payments as p
  join users as u on u.user_id = p.user_id
  group by u.channel
)
select channel, payment_cnt, share_pct
from shares
where payment_cnt >= 150
order by payment_cnt desc;

-- organic  | 566 | 45.2
-- referral | 384 | 30.7
-- partner  | 198 | 15.8

Почему оконную функцию нельзя поставить в WHERE

Типичная задача: найти первый платёж каждого пользователя. Нумеруем платежи через row_number() и хотим оставить строки с номером 1. Написать where row_number() over (...) = 1 нельзя: PostgreSQL отвечает «window functions are not allowed in WHERE», DuckDB — «WHERE clause cannot contain window functions». Причина та же, что с алиасом, только шагов между ними больше: WHERE второй, окна — пятые.

В HAVING оконную функцию тоже не поставить: он идёт перед окнами. Остаётся разнести запрос на два уровня. Внутренний нумерует строки, внешний фильтрует по номеру, который для него уже обычный столбец. Запрос ниже так и устроен: он берёт первый платёж каждого из 912 плательщиков и считает, с какого тарифа люди начинают. Чаще всего это basic — 514 первых платежей, у pro 296, у team 102.

В DuckDB, Snowflake и BigQuery для этой задачи есть слово QUALIFY — фильтр, который выполняется после оконных функций, как HAVING после агрегатов. В PostgreSQL его нет, поэтому в курсах и в этой статье используется вариант с CTE: он работает везде.

Номер строки считается в CTE, фильтр по нему — во внешнем запросе
with numbered as (
  select user_id, plan,
         row_number() over (partition by user_id order by paid_at, payment_id) as rn
  from payments
)
select plan, count(*) as first_payments
from numbered
where rn = 1
group by plan
order by first_payments desc;

-- basic | 514
-- pro   | 296
-- team  | 102

SELECT и DISTINCT: дубли убираются среди готовых строк

SELECT — шестой шаг. Здесь вычисляются выражения и появляются алиасы. Всё, что идёт дальше, работает уже со строками результата.

DISTINCT выполняется сразу после SELECT и сравнивает строки результата целиком, по всем столбцам. Он не знает, какой из столбцов вы считаете «главным». select distinct channel from users возвращает четыре строки: каналов четыре. Стоит добавить в список столбец, который у каждой строки свой, и DISTINCT перестаёт что-либо убирать.

Во втором подзапросе такой столбец — номер строки. Оконная функция считается раньше DISTINCT, поэтому к моменту удаления дублей у каждой из 4613 строк уже есть собственный номер, и одинаковых строк нет. Тот же эффект дают идентификатор или время события в списке SELECT.

Обратный случай: DISTINCT применяют, чтобы спрятать дубли после неудачного JOIN. Он уберёт повторы в выводе, но sum и count к этому моменту уже посчитаны по размноженным строкам. Чинить нужно первый шаг, а не седьмой. Подробнее — в статье про DISTINCT.

DISTINCT после оконной функции: с номером строки все 4613 строк разные
select 1 as n, 'distinct channel' as variant, count(*) as row_cnt
from (select distinct channel from users) as t
union all
select 2, 'distinct channel, row_number()', count(*)
from (
  select distinct channel, row_number() over (order by user_id) as rn
  from users
) as t
order by n;

-- 4 и 4613

ORDER BY и LIMIT: последние шаги

ORDER BY выполняется после SELECT. Это единственная часть запроса, где алиас работает в любой базе: order by revenue desc в первом примере. До ORDER BY порядок строк не определён. Таблица в базе — не список, и без явной сортировки строки могут прийти в любом порядке, причём при следующем запуске — в другом.

LIMIT срабатывает самым последним и обрезает готовый результат. Из этого следуют две ошибки. Первая: LIMIT без ORDER BY возвращает произвольные строки, а не «первые» или «последние». Вторая интереснее: LIMIT нельзя использовать, чтобы ограничить данные для агрегата.

Допустим, нужно среднее по десяти последним платежам. Запрос select avg(amount) from payments limit 10 выглядит подходящим, но вернёт 24,49 — среднее по всем 1251 платежам. К моменту LIMIT агрегат уже посчитан, в результате одна строка, и LIMIT 10 оставляет её как есть. Правильный запрос сначала отбирает десять строк в подзапросе, с сортировкой и LIMIT, а потом считает по ним среднее: 22,00.

В сортировке подзапроса два столбца: paid_at desc, payment_id desc. Дата платежа не уникальна, и без второго столбца десятая строка выбиралась бы случайно среди платежей одного дня. Про связку сортировки и обрезки — статья ORDER BY и LIMIT.

Среднее по десяти последним платежам: строки отбирает подзапрос, агрегат считает внешний запрос
select 1 as n, 'все платежи' as variant, round(avg(amount), 2) as avg_amount
from payments
union all
select 2, 'десять последних', round(avg(amount), 2)
from (
  select amount
  from payments
  order by paid_at desc, payment_id desc
  limit 10
) as last_payments
order by n;

-- 24.49 и 22.00

Логический порядок — это не план выполнения

Всё описанное выше — логический порядок. Он отвечает на два вопроса: какой результат вернёт запрос и какие имена в какой его части доступны. На вопрос «что база делает физически» он не отвечает.

Реальный порядок действий выбирает оптимизатор. Он вправе переставлять шаги как угодно, если результат от этого не изменится. Самая частая перестановка — фильтр раньше соединения. В запросе ниже условие u.country = 'KZ' записано в WHERE, то есть логически применяется после JOIN. В плане PostgreSQL видно другое: фильтр по стране стоит внутри чтения таблицы users, и в соединение попадают только пользователи из Казахстана. Результат тот же, работы меньше.

План читается снизу вверх и изнутри наружу: сначала чтение таблиц, потом соединение, группировка и сортировка. Отсюда практический вывод. Не нужно переписывать запрос, перенося условия из WHERE в подзапросы «чтобы отфильтровать пораньше»: для обычного JOIN база сделает это сама. А когда запрос работает медленно, смотреть нужно не на порядок слов, а на план. Как его получить и прочитать — в статье про EXPLAIN ANALYZE.

Платежи пользователей из Казахстана по каналам
select u.channel, count(*) as payment_cnt
from payments as p
join users as u on u.user_id = p.user_id
where u.country = 'KZ'
group by u.channel
order by payment_cnt desc;

-- organic 137, referral 78, partner 38, paid_search 20
codeПлан этого запроса в PostgreSQL 14 (EXPLAIN (COSTS OFF)): фильтр по стране выполняется до соединения
Sort
  Sort Key: (count(*)) DESC
  ->  HashAggregate
        Group Key: u.channel
        ->  Hash Join
              Hash Cond: (p.user_id = u.user_id)
              ->  Seq Scan on payments p
              ->  Hash
                    ->  Seq Scan on users u
                          Filter: (country = 'KZ'::text)

Чем отличаются PostgreSQL и DuckDB

Сам порядок шагов в базах одинаковый. Различается строгость: насколько база готова подставить выражение вместо алиаса там, где по порядку его ещё нет. В таблице — то, что проверено запуском на PostgreSQL 14 и на DuckDB из песочницы симулятора.

PostgreSQL ближе к стандарту: алиас работает в ORDER BY и, как исключение, в GROUP BY. DuckDB разрешает его почти везде, включая соседний столбец того же SELECT: amount * 12 as yearly, yearly / 2 as half_year. MySQL, судя по документации, занимает место посередине: алиас допустим в GROUP BY, HAVING и ORDER BY, но не в WHERE.

Если запрос должен работать в нескольких базах, ориентируйтесь на строгий вариант. В WHERE и HAVING — выражение целиком, в ORDER BY — алиас без арифметики, для фильтра по вычисленному столбцу или оконной функции — CTE. Проверка заданий в курсах «SQL с нуля» и «Продвинутый SQL» подсказывает, когда принятый запрос опирается на вольность DuckDB.

Где виден алиас из SELECT
Место в запросеPostgreSQL 14DuckDB
WHEREошибка: столбца нетработает
GROUP BYработает; одноимённый столбец таблицы важнееработает; одноимённый столбец таблицы важнее
HAVINGошибка: столбца нетработает
ORDER BY, имя алиасаработаетработает
ORDER BY, выражение с алиасом (-user_cnt)ошибка: столбца нетработает
соседний столбец в SELECTошибка: столбца нетработает
оконная функция в WHEREошибкаошибка
QUALIFYнет такого словаесть

Как пользоваться порядком выполнения при отладке

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

Где проверить на практике

Запросы из статьи выполняются как есть в песочнице Симулятора SQL-аналитика. На странице «SQL онлайн» открыт редактор с другой учебной базой, авиаперевозками, и готовые примеры на те же шаги: WHERE, GROUP BY, HAVING, оконная функция. Задачи на эти темы с проверкой ответа собраны в каталоге задач по SQL.

  • Начните с FROM и JOIN: select count(*) без фильтров. Если строк больше, чем в главной таблице, соединение их размножило.
  • Добавьте WHERE и сравните число строк до и после. Разницу нужно уметь объяснить.
  • Добавьте GROUP BY и проверьте, что сумма по группам равна числу строк с предыдущего шага.
  • HAVING, окна и DISTINCT добавляйте по одному. Если в запросе есть и HAVING, и оконная функция, проверьте, от чего считается знаменатель.
  • ORDER BY и LIMIT — в конце. Сортировка должна быть однозначной, иначе LIMIT будет возвращать разные строки от запуска к запуску.

Частые вопросы про порядок выполнения SQL-запроса

В каком порядке выполняются операторы SQL-запроса?

FROM и JOIN, затем WHERE, GROUP BY, HAVING, оконные функции, SELECT, DISTINCT, ORDER BY и в конце LIMIT с OFFSET. Это логический порядок: по нему определяется результат, а физический план база строит сама.

Почему алиас не работает в WHERE, но работает в ORDER BY?

WHERE выполняется до SELECT, и имени столбца для него ещё нет. ORDER BY выполняется после SELECT и алиас видит. В WHERE выражение повторяют целиком или выносят расчёт в CTE.

Что выполняется раньше — WHERE или HAVING?

WHERE. Он фильтрует строки до группировки, поэтому влияет на значения агрегатов. HAVING фильтрует готовые группы и значения не меняет.

Можно ли использовать алиас в GROUP BY?

В PostgreSQL и DuckDB — да, как и номер столбца. Если алиас совпадает с именем столбца таблицы, группировка пойдёт по столбцу таблицы, поэтому вычисленным столбцам дают имена, которых в таблицах нет.

Когда выполняются оконные функции?

После WHERE, GROUP BY и HAVING, перед DISTINCT и ORDER BY. Поэтому результат оконной функции нельзя проверить в WHERE и HAVING того же запроса: фильтр пишут во внешнем запросе.

Выполняется ли JOIN раньше WHERE?

Логически да: сначала соединение, потом фильтр. Физически оптимизатор часто фильтрует таблицу до соединения, если результат от этого не меняется.

Что выполняется раньше — SELECT или ORDER BY?

SELECT. Сортировка работает с готовыми столбцами результата, поэтому в ней можно использовать алиас. LIMIT применяется уже к отсортированным строкам.

Что попробовать на учебной базе

Три упражнения на те же шаги. Первое: возьмите запрос с долями каналов и замените HAVING на where u.channel <> 'paid_search'. Прежде чем запускать, решите, изменятся ли доли и почему. Второе: посчитайте число пользователей по странам и оставьте страны, где их больше 500, — сначала через HAVING, потом через CTE с алиасом в WHERE; оба запроса должны вернуть три строки. Третье: найдите последний платёж каждого пользователя и посчитайте, сколько из них пришлось на тариф team.

Если хочется пройти эти темы по порядку и с проверкой каждого запроса, они разобраны в курсе «SQL с нуля»: первые 10 уроков бесплатно, фильтр по группам и порядок шагов — в уроке про HAVING.

Продолжить чтение
Вся библиотека