Урок 06.03: Основы SQL (SELECT, JOIN, GROUP BY) — углублённый курс для аналитика
Цель урока
Освоить базовый и продвинутый-базовый синтаксис SQL для сбора и анализа данных. Научиться читать и писать запросы SELECT с фильтрацией (WHERE), объединением таблиц (JOIN), агрегацией (GROUP BY) и сортировкой (ORDER BY). Разобрать ловушки, в которые попадается каждый начинающий аналитик — в первую очередь превращение LEFT JOIN в INNER JOIN через неправильное условие в WHERE. Глубоко изучить работу функций COALESCE и CASE WHEN в аналитических отчётах.
Ключевые понятия
| Термин | Определение | Пример |
|---|---|---|
| SQL | Structured Query Language — язык структурированных запросов | SELECT * FROM users |
| DML | Data Manipulation Language — команды работы с данными | SELECT, INSERT, UPDATE, DELETE |
| DDL | Data Definition Language — команды работы со структурой | CREATE, ALTER, DROP |
| SELECT | Запрос на чтение данных | SELECT name FROM users |
| JOIN | Объединение таблиц по ключу | t JOIN u ON t.user_id = u.id |
| GROUP BY | Группировка строк для агрегации | GROUP BY status |
| HAVING | Фильтрация после GROUP BY | HAVING COUNT(*) > 5 |
| NULL | Отсутствие значения (не 0, не пустая строка) | WHERE email IS NULL |
| COALESCE | Функция подстановки значения вместо NULL | COALESCE(phone, '—') |
| CASE WHEN | Условная логика в SQL | CASE WHEN status='Done' THEN 1 ELSE 0 END |
| Предикат | Логическое условие, возвращающее TRUE/FALSE/NULL | status = 'Done' |
1. Зачем аналитику SQL?
1.1. SQL — не только для разработчиков
Многие ошибочно считают SQL «языком для программистов». На практике системный аналитик пишет SQL чаще, чем половина разработчиков.
| Задача аналитика | Как помогает SQL | Реальный пример |
|---|---|---|
| Валидация требований | Написать запрос, который должен выдавать правильный результат | Требование: «показать проекты с активными задачами». Пишете SELECT — видите, что проекты без задач не выводятся → обсуждаете с заказчиком: «Точно скрывать пустые проекты?» |
| Анализ данных заказчика | Быстро ответить на вопрос бизнеса без участия разработчика | «Сколько заказов со статусом "Ошибка" пришло за последний месяц?» |
| Прототипирование отчётов | До утверждения макета — показать заказчику живые данные | «Вот как будет выглядеть отчёт по просроченным задачам» |
| Проверка тестовых данных | Убедиться, что тестовые сценарии покрывают реальные кейсы | «Выберите задачи, у которых дедлайн просрочен, а статус не Done» |
| Коммуникация с разработчиком | «Вот SQL, который показывает, что я ожидаю от API / отчёта» | Разработчик смотрит запрос и понимает: нужен JOIN трёх таблиц с фильтром по дате |
Принцип: «SQL — это язык, на котором аналитик разговаривает с данными. Если вы не пишете SQL — вы общаетесь с данными через переводчика (разработчика), и что-то теряется при переводе.»
1.2. Структура этого урока
Базовый SELECT (FROM / WHERE / ORDER BY)
↓
JOIN (INNER → LEFT → FULL)
↓
⚠️ ЛОВУШКА: фильтр по правой таблице в WHERE
↓
GROUP BY + агрегатные функции
↓
🔧 COALESCE — укрощение NULL
↓
🔧 CASE WHEN — условная логика в SQL
↓
Порядок выполнения запроса
↓
Практические задания
2. Базовый SELECT — чтение данных
2.1. Минимальный запрос
SELECT * FROM tasks;
Результат: Все колонки, все строки таблицы tasks.
⚠️ Правило для аналитика: Никогда не используйте SELECT * в рабочих запросах, отчётах и коде. Всегда перечисляйте колонки явно:
- Вы не сломаете отчёт, если в таблицу добавили новую колонку
- Вы не прочитаете лишние данные (важно для больших таблиц)
- Другие люди увидят, какие данные вы ожидаете
2.2. SELECT с конкретными колонками
SELECT id, title, status, priority
FROM tasks;
Когда нужен псевдоним (alias):
SELECT
id,
title AS task_title, -- переименовали колонку
status,
priority
FROM tasks;
AS можно опустить: title task_title — работает, но с AS читается лучше.
2.3. SELECT с выражениями
SELECT
id,
title,
CONCAT(priority, ' | ', status) AS priority_and_status,
CURRENT_DATE - deadline::DATE AS days_overdue
FROM tasks;
Здесь мы уже создаём новые данные из существующих — ключевой навык аналитика.
2.4. Фильтрация WHERE
SELECT id, title, status
FROM tasks
WHERE status = 'In Progress';
Полная таблица операторов WHERE
| Оператор | Описание | Пример | Результат |
|---|---|---|---|
= |
Равно | status = 'Done' |
Строки со статусом Done |
<> или != |
Не равно | priority <> 'Low' |
Всё, кроме Low |
> , < |
Больше, меньше | created_at > '2026-01-01' |
Задачи после 01.01.2026 |
>= , <= |
Больше или равно, меньше или равно | deadline <= CURRENT_DATE |
Дедлайн сегодня или раньше |
BETWEEN |
Между (включительно) | amount BETWEEN 100 AND 1000 |
100 ≤ amount ≤ 1000 |
IN |
В списке | status IN ('To Do', 'In Progress') |
Любой из перечисленных |
NOT IN |
Не в списке | status NOT IN ('Archived', 'Cancelled') |
Все, кроме перечисленных |
LIKE |
Поиск по шаблону | email LIKE '%@company.com' |
Все корпоративные email |
ILIKE |
LIKE без учёта регистра (PostgreSQL) | name ILIKE '%иван%' |
Иван, иван, ИВАН |
IS NULL |
Проверка на NULL | assignee_id IS NULL |
Задачи без исполнителя |
IS NOT NULL |
Проверка на не-NULL | deadline IS NOT NULL |
Задачи с дедлайном |
AND |
Логическое И | status = 'Done' AND priority = 'High' |
Высокоприоритетные сделанные |
OR |
Логическое ИЛИ | status = 'To Do' OR status = 'In Progress' |
Незавершённые задачи |
⚠️ Важно для аналитика — приоритет AND/OR:
-- Что быстрее: AND связывает сильнее, чем OR
SELECT * FROM tasks
WHERE status = 'Done' OR status = 'In Progress' AND priority = 'High';
Это не то же самое, что:
SELECT * FROM tasks
WHERE (status = 'Done' OR status = 'In Progress') AND priority = 'High';
В первом случае: Done ИЛИ (In Progress AND High) — результат неожиданный. Во втором: (Done OR In Progress) AND High — то, что обычно нужно.
Правило: всегда ставьте скобки при смешивании AND и OR.
2.5. Сортировка ORDER BY
SELECT id, title, created_at
FROM tasks
WHERE assignee_id = 42
ORDER BY created_at DESC;
| Ключевое слово | Значение |
|---|---|
ASC |
По возрастанию (по умолчанию): 1, 2, 3 ... A, B, C |
DESC |
По убыванию: 3, 2, 1 ... Z, Y, X |
Сортировка по нескольким полям:
SELECT title, priority, status, created_at
FROM tasks
ORDER BY priority DESC, created_at ASC;
Сначала все Critical, внутри них — по дате создания (от старых к новым).
Сортировка с NULL:
NULL ведёт себя по-особенному:
- В PostgreSQL: NULLs LAST по умолчанию для ASC, NULLs FIRST для DESC
- В MySQL: NULLs FIRST для ASC
- Можно явно:
ORDER BY deadline NULLS LAST
SELECT title, deadline
FROM tasks
ORDER BY deadline NULLS LAST; -- задачи без дедлайна в конце
2.6. LIMIT / OFFSET (пагинация и топ-N)
SELECT title, priority
FROM tasks
ORDER BY priority DESC
LIMIT 10 OFFSET 0;
LIMIT 10 — вернуть 10 строк.
OFFSET 0 — пропустить 0 строк (начать с первой).
OFFSET 10 — пропустить первые 10 (следующая страница).
Реальный кейс: «Топ-5 просроченных задач»:
SELECT title, deadline, assignee_id
FROM tasks
WHERE deadline < CURRENT_DATE AND status != 'Done'
ORDER BY deadline ASC
LIMIT 5;
3. JOIN: объединение таблиц — ключевая компетенция аналитика
3.1. Зачем нужен JOIN
После нормализации данные разложены по разным таблицам. Чтобы собрать их вместе — нужен JOIN.
Без JOIN:
SELECT assignee_id, title FROM tasks WHERE id = 5;
-- Результат: assignee_id = 3, title = 'Багрепорт #42'
Вы узнали, что исполнитель — user_id = 3. Но кто это? Нужно делать второй запрос:
SELECT name FROM users WHERE id = 3;
-- Результат: 'Пётр'
С JOIN:
SELECT t.title, u.name
FROM tasks t
JOIN users u ON t.assignee_id = u.id
WHERE t.id = 5;
-- Результат: 'Багрепорт #42', 'Пётр'
Один запрос вместо двух — эффективнее, читаемее, транзакционно согласованнее.
3.2. Диаграмма Венна для JOIN
INNER JOIN LEFT JOIN
┌──────┐ ┌──────┐
│ A │ ∩ │ B │ │ A │─────│ B │
└──────┘ └──────┘ └──────┘ └──────┘
Только совпадения Все A + совпадения B
NULL, если нет совпадения
RIGHT JOIN FULL OUTER JOIN
┌──────┐ ┌──────┐
│ A │─────│ B │ │ A │─────│ B │
└──────┘ └──────┘ └──────────────┘
Все B + совпадения A Все строки из обеих таблиц
3.3. INNER JOIN — только совпадения
SELECT t.title, u.name AS assignee_name
FROM tasks t
INNER JOIN users u ON t.assignee_id = u.id;
| title | assignee_name |
|---|---|
| Настроить CI/CD | Анна |
| Написать API | Анна |
| Сверстать форму | Иван |
| Багрепорт #42 | Пётр |
| Тест-кейсы | Пётр |
Заметили? Задача «Рефакторинг» (assignee_id = NULL) не попала в результат. Потому что нет совпадения в users.
3.4. LEFT JOIN — все из левой, + совпадения из правой
SELECT t.title, u.name AS assignee_name
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id;
| title | assignee_name |
|---|---|
| Настроить CI/CD | Анна |
| Написать API | Анна |
| Сверстать форму | Иван |
| Багрепорт #42 | Пётр |
| Рефакторинг | NULL |
| Тест-кейсы | Пётр |
✅ Задача «Рефакторинг» теперь видна. Исполнитель — NULL (не назначена).
Когда выбирать:
| Сценарий | Какой JOIN | Почему |
|---|---|---|
| «Показать задачи, у которых есть исполнитель» | INNER JOIN | Задачи без исполнителя не нужны |
| «Показать все задачи, даже если исполнитель не назначен» | LEFT JOIN | Нужны все строки из tasks |
| «Показать всех пользователей и их задачи (даже если задач нет)» | LEFT JOIN с users слева | users — левая таблица |
| «Полный справочник: все пары пользователь-задача» | FULL OUTER JOIN | Редко, но бывает |
4. ⚠️ ЛОВУШКА НОВИЧКА: Как фильтрация по правой таблице в WHERE ломает LEFT JOIN
Это самая распространённая ошибка начинающих аналитиков. Она стоит часов отладки и нервных срывов. Разберём её на молекулярном уровне.
4.1. Проблема: ожидание vs реальность
Бизнес-задача: «Показать все проекты и количество активных задач в каждом. Если в проекте нет активных задач — показать 0.»
Естественная попытка:
SELECT
p.name AS project,
COUNT(t.id) AS active_task_count
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
WHERE t.status IN ('To Do', 'In Progress') -- ← ВОТ ЛОВУШКА!
GROUP BY p.id, p.name;
Ожидаемый результат:
| project | active_task_count |
|---|---|
| Backend | 3 |
| Frontend | 2 |
| Mobile | 0 |
Реальный результат:
| project | active_task_count |
|---|---|
| Backend | 3 |
| Frontend | 2 |
| Mobile | — (строка отсутствует!) |
Проект Mobile пропал! Хотя он есть в projects. Почему?
4.2. Анатомия ошибки — пошагово
Шаг 1. LEFT JOIN создаёт строки, даже если совпадений нет:
После выполнения LEFT JOIN projects + tasks (пока без WHERE) мы имеем:
| p.id | p.name | t.id | t.status | t.project_id |
|---|---|---|---|---|
| 1 | Backend | 1 | 'To Do' | 1 |
| 1 | Backend | 2 | 'In Progress' | 1 |
| 1 | Backend | 4 | 'In Progress' | 1 |
| 1 | Backend | 5 | 'To Do' | 1 |
| 2 | Frontend | 3 | 'Done' | 2 |
| 2 | Frontend | 6 | 'In Progress' | 2 |
| 3 | Mobile | NULL | NULL | NULL |
Строка для Mobile существует! t.id = NULL, t.status = NULL.
Шаг 2. WHERE выполняется ПОСЛЕ JOIN:
WHERE t.status IN ('To Do', 'In Progress')
Проверяем для Mobile: NULL IN ('To Do', 'In Progress') → FALSE.
⚠️ NULL не равно ничему, даже самому себе. NULL не входит ни в один IN-список.
Шаг 3. Строка Mobile отфильтрована:
p.id = 3, p.name = 'Mobile' → удалена из результата
Итог: LEFT JOIN превратился в INNER JOIN, потому что WHERE отфильтровал строки с NULL из правой таблицы.
4.3. Два правильных способа решения
Способ 1: Перенести условие в ON
SELECT
p.name AS project,
COUNT(t.id) AS active_task_count
FROM projects p
LEFT JOIN tasks t
ON p.id = t.project_id
AND t.status IN ('To Do', 'In Progress') -- ✅ условие в ON
GROUP BY p.id, p.name;
Почему работает: ON выполняется до того, как строки соединяются. LEFT JOIN гарантирует, что левая строка (project) останется в результате, даже если правая (task) не подошла под условие.
Результат:
| project | active_task_count |
|---|---|
| Backend | 3 |
| Frontend | 1 |
| Mobile | 0 |
✅ Mobile на месте с 0.
Способ 2: Явно учесть NULL в WHERE
SELECT
p.name AS project,
COUNT(t.id) AS active_task_count
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
WHERE t.status IN ('To Do', 'In Progress')
OR t.id IS NULL -- ✅ явно включаем NULL
GROUP BY p.id, p.name;
Тоже работает, но сложнее читается и менее производительно (OR усложняет план запроса).
4.4. Ещё один коварный пример
Задача: «Вывести всех пользователей и их задачи с приоритетом High. Если у пользователя нет High-задач — показать пользователя с NULL в колонке задачи.»
❌ Неправильно:
SELECT u.name, t.title
FROM users u
LEFT JOIN tasks t ON u.id = t.assignee_id
WHERE t.priority = 'High';
Результат: Пользователи, у которых вообще нет High-задач — пропадут. LEFT JOIN превратился в INNER JOIN.
✅ Правильно:
SELECT u.name, t.title
FROM users u
LEFT JOIN tasks t
ON u.id = t.assignee_id
AND t.priority = 'High'; -- ✅ условие в ON
Результат:
| name | title |
|---|---|
| Анна | Настроить CI/CD |
| Анна | NULL |
| Иван | NULL |
| Пётр | Тест-кейсы |
| Мария | NULL |
Мария появилась (хотя у неё вообще нет задач) ✅
4.5. Золотое правило
Если вы делаете LEFT JOIN и фильтруете по колонкам из правой (присоединённой) таблицы в WHERE — скорее всего, вы ошибаетесь.
Запомните:
- Условия на ключ соединения — в
ON- Условия на таблицу из FROM — в
WHERE- Условия на правую таблицу при LEFT JOIN — в
ON(если вы хотите сохранить все строки левой таблицы)
| Место условия | Что происходит |
|---|---|
ON (ключ) |
Определяет, как соединять строки |
ON (доп. фильтр) |
Фильтрует правую таблицу ДО соединения. Левая строка сохраняется |
WHERE (колонка из левой) |
Фильтрует результат после соединения — безопасно |
WHERE (колонка из правой) |
ОПАСНО! Превращает LEFT в INNER, отфильтровывая NULL-строки |
4.6. Таблица-шпаргалка «LEFT JOIN + условие»
| Что нужно показать | Условие по правой таблице | Куда ставить |
|---|---|---|
| Все проекты + их задачи To Do (проекты без To Do — с 0) | t.status = 'To Do' |
ON |
| Все пользователи + их задачи (даже если задач нет) | Нет доп. условия | Просто LEFT JOIN |
| Все задачи + их исполнители (но только если роль dev) | u.role = 'developer' |
ON (если хотим все задачи) или WHERE (если хотим только задачи с dev) |
| Все клиенты + заказы за последний месяц | o.created_at > now() - interval '1 month' |
ON |
5. GROUP BY: группировка и агрегация
5.1. Агрегатные функции
| Функция | Описание | Пример | Результат |
|---|---|---|---|
COUNT(*) |
Количество строк в группе | COUNT(*) |
Все строки, включая NULL |
COUNT(column) |
Количество не-NULL значений | COUNT(assignee_id) |
Только строки с исполнителем |
COUNT(DISTINCT column) |
Количество уникальных значений | COUNT(DISTINCT status) |
Сколько разных статусов |
SUM(column) |
Сумма значений | SUM(price) |
Общая сумма |
AVG(column) |
Среднее арифметическое | AVG(price) |
Средняя цена |
MIN(column) |
Минимальное значение | MIN(created_at) |
Самая старая задача |
MAX(column) |
Максимальное значение | MAX(priority) |
Максимальный приоритет |
Важно про NULL в агрегации:
SELECT
AVG(price) AS avg_all, -- SUM(price) / COUNT(price) — NULL игнорируются
AVG(COALESCE(price, 0)) AS avg_with_zero, -- NULL считаются как 0
SUM(price) AS total_sum, -- NULL игнорируются
COUNT(*) AS total_rows, -- все строки
COUNT(price) AS non_null_prices -- только где price не NULL
FROM products;
Если в price есть NULL, AVG(price) и AVG(COALESCE(price, 0)) дадут разные результаты!
5.2. Простая группировка
Без группировки — одна строка для всей таблицы:
SELECT COUNT(*) FROM tasks WHERE status = 'Done';
-- 1 строка: 150
С группировкой — строка для каждой группы:
SELECT status, COUNT(*) AS task_count
FROM tasks
GROUP BY status
ORDER BY task_count DESC;
| status | task_count |
|---|---|
| In Progress | 45 |
| To Do | 30 |
| Done | 150 |
| Blocked | 5 |
5.3. GROUP BY с JOIN
Задача: «Сколько задач в каждом проекте? Включить проекты без задач.»
SELECT
p.name AS project_name,
COUNT(t.id) AS task_count
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name
ORDER BY task_count DESC;
Обратите внимание: в GROUP BY нужно перечислить все колонки из SELECT, которые не являются агрегатными. В PostgreSQL можно писать GROUP BY p.id (если name функционально зависит от id). В MySQL — можно GROUP BY p.id (нестрогий режим), но лучше перечислить все.
5.4. HAVING — фильтр ПОСЛЕ группировки
SELECT assignee_id, COUNT(*) AS task_count
FROM tasks
WHERE status IN ('To Do', 'In Progress') -- фильтр ДО (по строкам)
GROUP BY assignee_id
HAVING COUNT(*) > 5 -- фильтр ПОСЛЕ (по группам)
ORDER BY task_count DESC;
Разница WHERE vs HAVING:
| WHERE | HAVING |
|---|---|
| Фильтрует строки до GROUP BY | Фильтрует группы после GROUP BY |
| Не может использовать агрегатные функции | Может использовать агрегатные функции |
| Применяется к таблице/представлению | Применяется к результату GROUP BY |
WHERE price > 100 |
HAVING COUNT(*) > 5 |
Почему нельзя WHERE COUNT(*) > 5? Потому что на момент выполнения WHERE группировка ещё не произошла — счётчик не подсчитан. Порядок выполнения SQL (см. раздел 9) строго определён.
5.5. Множественная агрегация
SELECT
p.name AS project,
COUNT(t.id) AS total_tasks,
COUNT(t.id) FILTER (WHERE t.status = 'Done') AS done_tasks,
COUNT(t.id) FILTER (WHERE t.status IN ('To Do', 'In Progress')) AS active_tasks,
MAX(t.priority) AS max_priority,
MIN(t.created_at) AS oldest_task
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name;
Конструкция FILTER (WHERE ...) — PostgreSQL-specific. В других СУБД используется CASE WHEN (см. раздел 8).
6. 🔧 COALESCE: борьба с NULL в аналитических отчётах
6.1. Что такое COALESCE
COALESCE(value, default) — возвращает первое не-NULL значение из списка аргументов.
COALESCE(assignee_name, 'Не назначен') -- если NULL → 'Не назначен'
COALESCE(phone, email, 'Нет контактов') -- первый не-NULL
COALESCE(NULL, NULL, 'default') → 'default'
COALESCE(NULL, 42, NULL) → 42
6.2. Синтаксис и нюансы
COALESCE(arg1, arg2, arg3, ..., argN)
- Аргументов может быть от 2 до N (в PostgreSQL — до 100)
- Все аргументы должны быть совместимых типов (или СУБД сделает неявное приведение)
- Если все аргументы NULL — результат NULL
COALESCE— это синтаксический сахар надCASE WHEN arg1 IS NOT NULL THEN arg1 ELSE arg2 END
6.3. Сценарии использования в аналитике
Сценарий 1: Подстановка значения по умолчанию в отчёте
SELECT
u.name,
COALESCE(u.phone, '—') AS phone, -- прочерк вместо NULL
COALESCE(u.avatar_url, '/default-avatar.png') AS avatar
FROM users u;
Сценарий 2: Цепочка «запасных» полей
-- Показать контакт: предпочесть телефон, затем email, иначе — 'Нет данных'
SELECT
name,
COALESCE(phone, email, 'Нет контактов') AS contact
FROM users;
Реальный кейс: В CRM у контакта может быть рабочий телефон, мобильный, email. Отчёт должен показать хотя бы один.
Сценарий 3: Защита от NULL в агрегации
SELECT
p.name,
-- Без COALESCE: COUNT(t.id) уже игнорирует NULL → 0, но SUM может вернуть NULL!
COALESCE(SUM(t.story_points), 0) AS total_points,
COALESCE(AVG(t.priority_score), 0) AS avg_priority
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name;
SUM(NULL) → NULL. А отчёт ожидает число. COALESCE превращает NULL в 0.
Сценарий 4: Условная логика с вложенными COALESCE
SELECT
order_id,
COALESCE(
paid_at,
shipped_at,
created_at,
'2026-01-01'::DATE
) AS effective_date
FROM orders;
Берём дату оплаты. Если NULL — дату отгрузки. Если NULL — дату создания. Если всё NULL — 1 января 2026.
6.4. COALESCE vs CASE WHEN vs ISNULL
| СУБД | Функция | Примечание |
|---|---|---|
| PostgreSQL | COALESCE |
Стандарт SQL |
| PostgreSQL | ISNULL |
НЕТ (в MSSQL — есть) |
| MySQL | COALESCE |
Есть |
| MySQL | IFNULL(a, b) |
Только 2 аргумента |
| MSSQL | COALESCE |
Есть |
| MSSQL | ISNULL(a, b) |
Только 2 аргумента, но типизация отличается от COALESCE |
| Oracle | COALESCE |
Есть |
| Oracle | NVL(a, b) |
Только 2 аргумента |
Для аналитика: везде используйте COALESCE — это стандарт SQL, работает во всех СУБД.
6.5. Типичная ошибка с COALESCE
-- ❌ Неправильно: COALESCE не спасёт, если вся строка отфильтрована WHERE
SELECT p.name, COALESCE(COUNT(t.id), 0) AS task_count
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
WHERE t.status = 'Active' -- ← LEFT JOIN убит!
GROUP BY p.id, p.name;
Здесь COALESCE бесполезен — строка проекта удалена WHERE до того, как COALESCE выполнился. Сначала лечите JOIN-условие, потом оборачивайте в COALESCE.
7. ➕ JOIN нескольких таблиц
7.1. Три таблицы: задачи + проекты + пользователи
SELECT
t.title,
u.name AS assignee_name,
p.name AS project_name
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE p.status = 'active'
ORDER BY t.created_at DESC;
Визуализация:
tasks ──LEFT── users
│
└────LEFT── projects
7.2. Четыре таблицы: задачи + проекты + пользователи + метки
SELECT
t.title,
u.name AS assignee,
p.name AS project,
STRING_AGG(l.name, ', ') AS labels
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN task_labels tl ON t.id = tl.task_id
LEFT JOIN labels l ON tl.label_id = l.id
WHERE p.status = 'active'
GROUP BY t.id, t.title, u.name, p.name
ORDER BY t.created_at DESC;
STRING_AGG — PostgreSQL-функция для сбора меток в строку через запятую.
8. 🔧 CASE WHEN: условная логика в SQL
8.1. Два синтаксиса
Простой CASE (simple CASE):
SELECT
title,
CASE status
WHEN 'To Do' THEN 'Нужно сделать'
WHEN 'In Progress' THEN 'В работе'
WHEN 'Done' THEN 'Готово'
WHEN 'Blocked' THEN 'Заблокировано'
ELSE 'Неизвестный статус'
END AS status_ru
FROM tasks;
Работает как switch: сравнивает status с каждым WHEN.
Поисковый CASE (searched CASE):
SELECT
title,
CASE
WHEN priority = 'Critical' AND deadline < CURRENT_DATE THEN 'Критическая просрочка'
WHEN priority = 'High' AND deadline < CURRENT_DATE THEN 'Просрочка'
WHEN status = 'Done' THEN 'Завершено'
WHEN deadline IS NULL THEN 'Нет дедлайна'
ELSE 'В работе'
END AS task_category
FROM tasks;
Второй гибче: можно использовать разные колонки, сложные условия, AND/OR.
8.2. CASE WHEN в реальных аналитических задачах
Задача 1: Бакетизация (bucketing) — разбивка на группы
SELECT
CASE
WHEN age BETWEEN 18 AND 25 THEN '18–25'
WHEN age BETWEEN 26 AND 35 THEN '26–35'
WHEN age BETWEEN 36 AND 50 THEN '36–50'
ELSE '50+'
END AS age_group,
COUNT(*) AS user_count
FROM users
GROUP BY age_group
ORDER BY age_group;
Бизнес-ценность: Маркетинг видит, какая возрастная группа активнее покупает.
Задача 2: Кастомная сортировка
Нельзя отсортировать статусы по алфавиту — бизнес хочет свой порядок.
SELECT DISTINCT status
FROM tasks
ORDER BY
CASE status
WHEN 'Critical' THEN 1
WHEN 'High' THEN 2
WHEN 'Medium' THEN 3
WHEN 'Low' THEN 4
ELSE 5
END;
Результат: Critical → High → Medium → Low → (остальные).
Задача 3: Условная агрегация (conditional aggregation) — Pivot
Хотим одну строку на проект с колонками по статусам:
SELECT
p.name AS project,
COUNT(*) AS total,
SUM(CASE WHEN t.status = 'Done' THEN 1 ELSE 0 END) AS done,
SUM(CASE WHEN t.status = 'In Progress' THEN 1 ELSE 0 END) AS in_progress,
SUM(CASE WHEN t.status = 'To Do' THEN 1 ELSE 0 END) AS to_do,
SUM(CASE WHEN t.status = 'Blocked' THEN 1 ELSE 0 END) AS blocked
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name;
Результат:
| project | total | done | in_progress | to_do | blocked |
|---|---|---|---|---|---|
| Backend | 4 | 0 | 2 | 2 | 0 |
| Frontend | 2 | 1 | 1 | 0 | 0 |
| Mobile | 0 | 0 | 0 | 0 | 0 |
Альтернатива с FILTER (PostgreSQL):
COUNT(*) FILTER (WHERE status = 'Done') AS done
Но CASE WHEN + SUM работает во всех СУБД — универсальный подход.
Задача 4: Приоритет-скоринг
Бизнес просит числовой показатель «срочности задачи» для сортировки:
SELECT
title,
deadline,
CASE
WHEN deadline IS NULL THEN 0
WHEN deadline < CURRENT_DATE THEN 100 -- просрочено
WHEN deadline <= CURRENT_DATE + INTERVAL '1 day' THEN 80 -- завтра
WHEN deadline <= CURRENT_DATE + INTERVAL '3 days' THEN 50 -- 3 дня
WHEN deadline <= CURRENT_DATE + INTERVAL '7 days' THEN 20 -- неделя
ELSE 5
END + CASE priority
WHEN 'Critical' THEN 50
WHEN 'High' THEN 30
WHEN 'Medium' THEN 10
WHEN 'Low' THEN 0
END AS urgency_score
FROM tasks
ORDER BY urgency_score DESC;
Задача 5: CASE WHEN в GROUP BY — кастомные группы
SELECT
CASE
WHEN total_amount < 1000 THEN 'Мелкий'
WHEN total_amount < 10000 THEN 'Средний'
WHEN total_amount < 100000 THEN 'Крупный'
ELSE 'VIP'
END AS order_segment,
COUNT(*) AS order_count,
SUM(total_amount) AS total_revenue
FROM orders
GROUP BY order_segment
ORDER BY total_revenue DESC;
8.3. Вложенный CASE WHEN
SELECT
CASE
WHEN role = 'admin' THEN
CASE
WHEN status = 'active' THEN 'Администратор (активен)'
ELSE 'Администратор (неактивен)'
END
WHEN role = 'developer' THEN
CASE
WHEN task_count > 10 THEN 'Разработчик (перегружен)'
ELSE 'Разработчик'
END
ELSE 'Другой'
END AS user_category
FROM ...
⚠️ Предупреждение: Если CASE WHEN вложен глубже 2 уровней — выносите логику в отдельную функцию или CTE. SQL читается сверху вниз, глубокие вложения нечитаемы.
8.4. COALESCE и CASE WHEN: сравнительная таблица
| Задача | COALESCE | CASE WHEN |
|---|---|---|
| Заменить NULL на значение по умолчанию | ✅ Идеально | ✅ Можно, но многословно |
| Цепочка «первый не-NULL» | ✅ Идеально | ✅ Можно |
| Сложное условие (не только NULL) | ❌ Нет | ✅ Идеально |
| Бакетизация (разбивка на группы) | ❌ Нет | ✅ Идеально |
| Проверка на равенство одному полю | ✅ Частично (только NULL) | ✅ Идеально |
| Условная агрегация (SUM + CASE) | ❌ Нет | ✅ Идеально |
9. Подзапросы (Subqueries)
9.1. Подзапрос в WHERE
SELECT title
FROM tasks
WHERE assignee_id IN (
SELECT id FROM users WHERE role = 'developer'
);
Что происходит: Сначала выполняется внутренний запрос (получаем всех разработчиков). Затем внешний — ищет задачи с этими id.
9.2. Подзапрос в SELECT (скалярный подзапрос)
SELECT
u.name,
(SELECT COUNT(*) FROM tasks WHERE assignee_id = u.id) AS task_count,
(SELECT MAX(priority) FROM tasks WHERE assignee_id = u.id) AS max_priority
FROM users u
ORDER BY task_count DESC;
Важно: Скалярный подзапрос должен возвращать ровно 1 строку и 1 колонку.
9.3. Подзапрос в FROM (производная таблица)
SELECT
dept.name,
dept.avg_salary
FROM (
SELECT
department_id,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING AVG(salary) > 50000
) dept
JOIN departments d ON dept.department_id = d.id;
Когда нужно: Когда агрегация делается на одном уровне, а JOIN — на другом.
9.4. CTE (Common Table Expression) — современная альтернатива подзапросам
WITH dept_stats AS (
SELECT
department_id,
COUNT(*) AS emp_count,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING COUNT(*) > 5
)
SELECT d.name, ds.emp_count, ds.avg_salary
FROM departments d
JOIN dept_stats ds ON d.id = ds.department_id
ORDER BY ds.avg_salary DESC;
CTE vs подзапрос:
| Критерий | Подзапрос | CTE (WITH) |
|---|---|---|
| Читаемость | Сложнее при вложении | ✅ Линейная структура |
| Переиспользование | ❌ Нельзя ссылаться дважды | ✅ Можно несколько раз |
| Рекурсия | ❌ Нет | ✅ Есть (RECURSIVE) |
| Производительность | Одинаково | Одинаково (оба материализуются или нет) |
Для аналитика: Используйте CTE для сложных запросов — они читаются как последовательность шагов.
10. Порядок выполнения SQL-запроса (Execution Order)
Это самая важная часть теории. Понимание порядка выполнения — ключ к написанию корректных запросов.
10.1. Логический порядок (не физический!)
SELECT -- 5
p.name,
COUNT(t.id) AS task_count
FROM projects p -- 1
LEFT JOIN tasks t ON p.id = t.project_id -- 2
WHERE p.status = 'active' -- 3
GROUP BY p.id, p.name -- 4
HAVING COUNT(t.id) > 0 -- 4.1
ORDER BY task_count DESC -- 6
LIMIT 10; -- 7
Подробно:
| Шаг | Ключевое слово | Что происходит | Почему это важно |
|---|---|---|---|
| 1 | FROM |
Определяем исходную таблицу | Все остальное — над этим результатом |
| 2 | JOIN |
Присоединяем вторую таблицу по ON | LEFT JOIN может добавить NULL-строки |
| 3 | WHERE |
Фильтруем строки | Нельзя использовать агрегатные функции — GROUP BY ещё не было |
| 4 | GROUP BY |
Группируем строки | Все неагрегированные колонки в SELECT — должны быть в GROUP BY |
| 4.1 | HAVING |
Фильтруем группы | Можно использовать агрегатные функции |
| 5 | SELECT |
Вычисляем выражения, псевдонимы | Псевдонимы из SELECT недоступны в WHERE/GROUP BY (они создаются позже) |
| 6 | ORDER BY |
Сортируем результат | Можно использовать псевдонимы из SELECT |
| 7 | LIMIT / OFFSET |
Ограничиваем вывод | Последний шаг |
10.2. Практические следствия
Следствие 1: Нельзя использовать псевдоним колонки в WHERE:
-- ❌ Ошибка: колонка task_count ещё не создана
SELECT name, COUNT(*) AS task_count
FROM tasks
WHERE task_count > 5
GROUP BY name;
-- ✅ Правильно: HAVING
SELECT name, COUNT(*) AS task_count
FROM tasks
GROUP BY name
HAVING COUNT(*) > 5;
Следствие 2: Нельзя использовать агрегатную функцию в WHERE:
-- ❌ Ошибка: AVG вычисляется в GROUP BY (шаг 4), а WHERE — шаг 3
SELECT name, AVG(price)
FROM products
WHERE AVG(price) > 100
GROUP BY name;
Следствие 3: ORDER BY может использовать псевдонимы (шаг 6 после шага 5):
SELECT name, COUNT(*) AS task_count
FROM tasks
GROUP BY name
ORDER BY task_count DESC; -- ✅ Работает
10.3. Шпаргалка «Порядок написания vs порядок выполнения»
ПОРЯДОК НАПИСАНИЯ: ПОРЯДОК ВЫПОЛНЕНИЯ:
SELECT FROM / JOIN
FROM WHERE
JOIN GROUP BY
WHERE HAVING
GROUP BY SELECT
HAVING ORDER BY
ORDER BY LIMIT / OFFSET
LIMIT / OFFSET
Разные порядки! Это сбивает новичков — привыкайте.
11. Типы данных в SQL (краткий справочник)
11.1. Основные типы
| Тип | Описание | Размер | Пример |
|---|---|---|---|
INTEGER / INT |
Целое число (стандартное) | 4 байта | 42 |
BIGINT |
Большое целое | 8 байт | 9876543210 |
SMALLINT |
Малое целое | 2 байта | 32000 |
DECIMAL(p,s) / NUMERIC |
Дробное с точностью | до 16+ байт | 1234.56 |
REAL / FLOAT |
Дробное с плавающей точкой | 4/8 байт | 3.14159 |
VARCHAR(n) |
Строка переменной длины | до n символов | 'Иванов' |
CHAR(n) |
Строка фиксированной длины | n символов | 'Иванов ' (с пробелами) |
TEXT |
Длинный текст | до 1 ГБ | 'Описание...' |
BOOLEAN |
Логическое | 1 байт | TRUE / FALSE |
DATE |
Дата | 4 байта | '2026-05-29' |
TIMESTAMP |
Дата + время (без часового пояса) | 8 байт | '2026-05-29 10:30:00' |
TIMESTAMPTZ |
Дата + время с часовым поясом | 8 байт | '2026-05-29 10:30:00+03' |
UUID |
Универсальный уникальный ID | 16 байт | '550e8400-e29b-41d4-a...' |
JSONB |
Бинарный JSON (PostgreSQL) | переменный | '{"key": "value"}' |
11.2. NULL — опасное отсутствие
-- NULL не равно NULL
SELECT NULL = NULL; → FALSE (NULL)
SELECT NULL IS NULL; → TRUE
-- NULL в выражениях
SELECT 42 + NULL; → NULL
SELECT 'A' || NULL; → NULL (в PostgreSQL)
SELECT CONCAT('A', NULL); → 'A' (в PostgreSQL, MySQL)
-- NULL в WHERE
WHERE name = NULL → ❌ Всегда ложно, даже если name = NULL
WHERE name IS NULL → ✅ Правильно
-- NULL в агрегации
SUM(NULL) → NULL
AVG(NULL) → NULL
COUNT(*) → считает все строки (включая NULL-строки)
COUNT(col) → считает только не-NULL
Правило на всю жизнь: NULL не участвует в сравнениях. NULL = anything → FALSE. NULL <> anything → FALSE. NULL IN (1,2,3) → FALSE. NULL NOT IN (1,2,3) → FALSE (даже NOT IN даёт FALSE!).
12. SQL для аналитика: реальные задачи
Задача 1: Валидация требований
Требование: «Система должна показывать только задачи, назначенные на текущего пользователя, в статусе "To Do" или "In Progress", отсортированные по приоритету (Critical → High → Medium → Low). Если у задачи истёк дедлайн — она должна быть в начале списка (помечена красным).»
SQL-прототип (что должен выдавать API):
SELECT
id,
title,
priority,
status,
deadline,
CASE
WHEN deadline < CURRENT_DATE THEN 'Просрочено'
ELSE 'В срок'
END AS deadline_status
FROM tasks
WHERE assignee_id = :current_user_id
AND status IN ('To Do', 'In Progress')
ORDER BY
CASE WHEN deadline < CURRENT_DATE THEN 0 ELSE 1 END, -- просроченные — первыми
CASE priority
WHEN 'Critical' THEN 1
WHEN 'High' THEN 2
WHEN 'Medium' THEN 3
WHEN 'Low' THEN 4
END;
Что проверяем:
- Фильтр по assignee_id — ✅
- Фильтр по статусам — ✅
- Сортировка: просроченные → Critical → High → Medium → Low — ✅
Требование подтверждено запросом. Можно передавать в разработку.
Задача 2: Подготовка данных для отчёта
Требование: «Покажите количество задач по приоритетам в каждом проекте. Включите проекты без задач. Отсортируйте по имени проекта.»
SELECT
p.name AS project_name,
COUNT(*) FILTER (WHERE t.priority = 'Critical') AS critical_cnt,
COUNT(*) FILTER (WHERE t.priority = 'High') AS high_cnt,
COUNT(*) FILTER (WHERE t.priority = 'Medium') AS medium_cnt,
COUNT(*) FILTER (WHERE t.priority = 'Low') AS low_cnt,
COUNT(t.id) AS total
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name
ORDER BY p.name;
Универсальная версия (CASE WHEN для любой СУБД):
SELECT
p.name AS project_name,
SUM(CASE WHEN t.priority = 'Critical' THEN 1 ELSE 0 END) AS critical_cnt,
SUM(CASE WHEN t.priority = 'High' THEN 1 ELSE 0 END) AS high_cnt,
SUM(CASE WHEN t.priority = 'Medium' THEN 1 ELSE 0 END) AS medium_cnt,
SUM(CASE WHEN t.priority = 'Low' THEN 1 ELSE 0 END) AS low_cnt,
COUNT(t.id) AS total
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name
ORDER BY p.name;
Задача 3: Data Quality — поиск проблем
Требование: «Найти задачи, у которых истёк дедлайн, но статус не Done.»
SELECT
t.id,
t.title,
t.deadline,
t.status,
CURRENT_DATE - t.deadline::DATE AS days_overdue,
COALESCE(u.name, 'Не назначен') AS assignee
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id
WHERE t.deadline < CURRENT_DATE
AND t.status != 'Done'
ORDER BY days_overdue DESC;
Бизнес-ценность: Ежедневный отчёт для руководителя: «Какие задачи горят?»
Задача 4: Аналитический дашборд (сводка по проектам)
WITH project_stats AS (
SELECT
p.id,
p.name,
COUNT(t.id) AS total_tasks,
COUNT(t.id) FILTER (WHERE t.status = 'Done') AS done_tasks,
COUNT(t.id) FILTER (WHERE t.deadline < CURRENT_DATE AND t.status != 'Done') AS overdue_tasks,
COUNT(t.id) FILTER (WHERE t.assignee_id IS NULL) AS unassigned_tasks,
ROUND(
COUNT(t.id) FILTER (WHERE t.status = 'Done')::NUMERIC
/ NULLIF(COUNT(t.id), 0) * 100,
1) AS completion_pct
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
GROUP BY p.id, p.name
)
SELECT
name,
total_tasks,
done_tasks,
overdue_tasks,
unassigned_tasks,
COALESCE(completion_pct, 0) AS completion_pct
FROM project_stats
ORDER BY overdue_tasks DESC;
Что здесь использовано:
CTE (WITH)— читаемый шаг за шагомFILTER (WHERE ...)— условная агрегацияNULLIF(COUNT(t.id), 0)— защита от деления на нольCOALESCE— замена NULL на 0 в процентахCASE WHEN— неявно через FILTER
13. Пример данных для самостоятельных экспериментов
-- Создание таблиц
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(255),
email VARCHAR(255) UNIQUE,
role VARCHAR(50)
);
CREATE TABLE projects (
id INT PRIMARY KEY,
name VARCHAR(255),
status VARCHAR(50)
);
CREATE TABLE tasks (
id INT PRIMARY KEY,
title VARCHAR(255),
status VARCHAR(50) DEFAULT 'To Do',
priority VARCHAR(50) DEFAULT 'Medium',
assignee_id INT REFERENCES users(id),
project_id INT REFERENCES projects(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deadline DATE
);
-- Наполнение данными
INSERT INTO users VALUES
(1, 'Анна', 'anna@mail.com', 'developer'),
(2, 'Иван', 'ivan@mail.com', 'developer'),
(3, 'Пётр', 'petr@mail.com', 'tester'),
(4, 'Мария', 'maria@mail.com', 'admin');
INSERT INTO projects VALUES
(1, 'Backend', 'active'),
(2, 'Frontend', 'active'),
(3, 'Mobile', 'archived');
INSERT INTO tasks VALUES
(1, 'Настроить CI/CD', 'To Do', 'High', 1, 1, '2026-05-20', '2026-06-01'),
(2, 'Написать API', 'In Progress', 'Critical', 1, 1, '2026-05-21', '2026-05-30'),
(3, 'Сверстать форму', 'Done', 'Medium', 2, 2, '2026-05-19', '2026-05-25'),
(4, 'Багрепорт #42', 'In Progress', 'Low', 3, 1, '2026-05-22', '2026-06-05'),
(5, 'Рефакторинг', 'To Do', 'Medium', NULL, 1, '2026-05-23', '2026-06-10'),
(6, 'Тест-кейсы', 'In Progress', 'High', 3, 2, '2026-05-24', '2026-06-02');
14. Вопросы для самопроверки
Базовый уровень
- Что такое JOIN и какие его основные виды вы знаете? Нарисуйте диаграммы Венна для каждого.
- Чем INNER JOIN отличается от LEFT JOIN? Приведите пример, когда LEFT JOIN даёт NULL в результате.
- В чём разница между WHERE и HAVING? Можно ли использовать
WHERE SUM(price) > 100? Почему? - Какие агрегатные функции SQL вы знаете? Как ведёт себя каждая при NULL?
- Каков порядок выполнения SQL-запроса? (7 шагов)
- Что такое NULL и как его правильно проверять? Почему
WHERE name = NULLне работает?
Продвинутый уровень (ловушки)
-
Ловушка LEFT JOIN: Дан запрос:
SELECT p.name, COUNT(t.id) FROM projects p LEFT JOIN tasks t ON p.id = t.project_id WHERE t.status = 'Active' GROUP BY p.id, p.name;Проект Mobile пропал из результата. Почему? Как исправить?
-
COALESCE: В чём разница между:
AVG(price) -- (1) AVG(COALESCE(price, 0)) -- (2)В каких ситуациях (1) и (2) дадут разные результаты? Какая строка правильнее для отчёта «Средняя цена товара»?
-
CASE WHEN: Напишите CASE WHEN, который превращает числовую оценку (1–5) в текстовую: 1 = «Ужасно», 2 = «Плохо», 3 = «Нормально», 4 = «Хорошо», 5 = «Отлично». Используйте оба синтаксиса: простой CASE и поисковый CASE.
-
Последовательность: Почему этот запрос выдаст ошибку?
SELECT name, COUNT(*) AS cnt FROM users WHERE cnt > 5 GROUP BY name;Как исправить?
-
LEFT JOIN + агрегация: Напишите запрос, который показывает для каждого пользователя количество задач, включая пользователей без задач. Условие: задачи должны быть со статусом 'In Progress'.
-
CASE WHEN в ORDER BY: У вас есть таблица
productsс колонкамиid, name, status, price. Статусы: 'in_stock', 'out_of_stock', 'discontinued'. Отсортируйте так, чтобы сначала шли товары в наличии, затем — отсутствующие, затем — снятые с производства. Внутри каждой группы — по убыванию цены.
15. Практическое задание
Задание 1. Напишите SQL-запросы (4 запроса, 1 балл)
Используйте схему Task Manager (раздел 13).
- Простые задачи пользователя: Вывести все задачи, назначенные на Анну (id=1), со статусом. Отсортировать по дате создания (сначала новые).
- Присоединение имени: Вывести названия задач и имена их исполнителей. Включить задачи без исполнителя (подсказка: LEFT JOIN).
- Агрегация по статусу: Для каждого статуса — количество задач. Отсортировать по убыванию количества.
- Проекты с задачами: Вывести название проекта, количество задач в нём, количество завершённых задач (Done). Включить проекты без задач. Отсортировать по имени проекта.
Задание 2. Исправьте ошибку (1 балл)
Дан запрос с ошибкой:
SELECT p.name AS project_name, COUNT(t.id) AS active_tasks
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id
WHERE t.status IN ('To Do', 'In Progress')
GROUP BY p.id, p.name
ORDER BY project_name;
Ожидание: Все проекты, включая те, где нет активных задач (должно быть 0).
Реальность: Проект Mobile отсутствует, а Frontend показывает 1 (а должно быть 1 — в нём одна In Progress задача).
Вопрос:
- Почему проект Mobile пропал?
- Почему Frontend показывает 1, хотя визуально в нём 2 задачи? (Подсказка: посмотрите на данные — у задачи 3 статус 'Done')
- Исправьте запрос так, чтобы он работал корректно. Напишите ДВА варианта исправления.
Задание 3. CASE WHEN + COALESCE (1 балл)
Напишите запрос, который выводит:
| Колонка | Описание |
|---|---|
project |
Имя проекта |
total_tasks |
Всего задач в проекте |
done_pct |
Процент завершённых задач (округлённый до 1 знака) |
status_summary |
Текстовое описание: |
— Если done_pct = 100: ✅ «Все задачи завершены» |
|
— Если done_pct >= 50: ⏳ «Больше половины сделано» |
|
— Если done_pct > 0: 🚧 «В работе» |
|
— Если done_pct = 0 или задач нет: ❌ «Нет завершённых задач» |
|
top_priority |
Самый высокий приоритет среди задач проекта (текстом). Если задач нет — NULL → заменить на «Нет задач» через COALESCE |
Требования:
- LEFT JOIN (включить проекты без задач)
- Использовать
CASE WHENхотя бы 2 раза - Использовать
COALESCEхотя бы 1 раз - Защита от деления на ноль (
NULLIF)
Задание 4. Сложный аналитический запрос (1 балл)
Постановка: Менеджер хочет видеть «доску контроля проектов»:
Проект | Всего | Активные | Просроченные | Без исполнителя | Прогресс
Backend | 4 | 4 | 1 | 1 | 0%
Frontend | 2 | 1 | 0 | 0 | 50%
Mobile | 0 | 0 | 0 | 0 | —
- Активные = статус IN ('To Do', 'In Progress')
- Просроченные = deadline < CURRENT_DATE AND статус != 'Done'
- Без исполнителя = assignee_id IS NULL
- Прогресс = ROUND(Done / Всего * 100, 0) || '%' (если задач нет — «—»)
Напишите один запрос, который выдаёт эту таблицу. Используйте CTE (WITH) для читаемости.
Задание 5. Бонус: Анализ данных (0.5 балла, не обязательно)
Используя тестовые данные, напишите запрос, который находит задачи-сироты: задачи, у которых project_id ссылается на несуществующий проект. (Подсказка: LEFT JOIN + WHERE ... IS NULL.)
16. Дополнительные материалы
- Книга: Алан Бьюли — «Изучаем SQL» (Head First SQL) — для начинающих с картинками
- Книга: Бен Форта — «SQL за 10 минут» — шпаргалка по синтаксису (носить с собой)
- Книга: Ицик Бен-Ган — «T-SQL. Основы» — для глубокого понимания (MSSQL, но теория общая)
- Инструмент: db-fiddle.com — онлайн-песочница SQL (напечатал → запустил → получил результат)
- Инструмент: pgadmin.org — GUI для PostgreSQL (визуальный EXPLAIN)
- Практика: SQLZoo (sqlzoo.net) — интерактивные упражнения по SQL
- Практика: pgexercises.com — упражнения по PostgreSQL (от простого к сложному)
- Шпаргалка: «SQL Cheat Sheet» на learnsql.com — одностраничный справочник
- Шпаргалка: «SQL Order of Operations» — порядок выполнения запроса (распечатать на стену)
- Статья: «Common SQL JOIN Mistakes» — разбор типичных ошибок (включая LEFT JOIN trap)