Найти в Дзене

SQL Formatter: почему форматирование кода важнее, чем кажется | SQL Lab

Теги: SQL, Code Style, PostgreSQL, Инструменты разработчика, Best Practices Возьмём два запроса. Оба делают одно и то же. Найдите ошибку в первом: select u.id,u.name,count(o.id) as orders,sum(o.amount) as total from users u left join orders o on o.user_id=u.id where u.created_at>='2024-01-01' and u.is_active=true group by u.id,u.name having count(o.id)>0 order by total desc limit 50 А теперь во втором: SELECT
u.id,
u.name,
COUNT(o.id) AS orders,
SUM(o.amount) AS total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2024-01-01'
AND u.is_active = true
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 0
ORDER BY total DESC
LIMIT 50; В первом LEFT JOIN — то есть COUNT(o.id) может возвращать 0, и условие HAVING COUNT(o.id) > 0 тогда не нужно… или нужно? Непонятно что хотел автор. Во втором запросе проблема видна сразу: LEFT JOIN + HAVING COUNT > 0 эквивалентно INNER JOIN, и это почти наверняка не то что задумывалось. Форматирование не косметика.
Оглавление

Теги: SQL, Code Style, PostgreSQL, Инструменты разработчика, Best Practices

Возьмём два запроса. Оба делают одно и то же. Найдите ошибку в первом:

https://sqllab.ru/tools/formatter
https://sqllab.ru/tools/formatter

select u.id,u.name,count(o.id) as orders,sum(o.amount) as total from users u left join orders o on o.user_id=u.id where u.created_at>='2024-01-01' and u.is_active=true group by u.id,u.name having count(o.id)>0 order by total desc limit 50

А теперь во втором:

SELECT
u.id,
u.name,
COUNT(o.id) AS orders,
SUM(o.amount) AS total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2024-01-01'
AND u.is_active = true
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 0
ORDER BY total DESC
LIMIT 50;

В первом LEFT JOIN — то есть COUNT(o.id) может возвращать 0, и условие HAVING COUNT(o.id) > 0 тогда не нужно… или нужно? Непонятно что хотел автор. Во втором запросе проблема видна сразу: LEFT JOIN + HAVING COUNT > 0 эквивалентно INNER JOIN, и это почти наверняка не то что задумывалось.

Форматирование не косметика. Это инструмент мышления.

Почему плохое форматирование стоит денег

Ситуация 1: код-ревью

Аналитик отправляет запрос на ревью лиду. Лид видит простыню в одну строку. Первые 5 минут уходит на то чтобы вообще понять что делает запрос. Потом ещё 5 на поиск ошибки. Потом комментарий «отформатируй и пришли снова».

Итог: 30 минут потеряно с обеих сторон, задача в стопоре.

Ситуация 2: дебаггинг в 23:00

Прод упал. Медленный запрос блокирует базу. Вы открываете логи, копируете запрос — и видите 800 символов в одну строку без единого переноса. EXPLAIN ANALYZE показывает что проблема где-то в джойнах — но каких? Их там пять.

Хорошо отформатированный запрос читается за 10 секунд. Плохой — за 10 минут.

Ситуация 3: наследование чужого кода

Вы переходите в новую компанию. Вам передают «дашборд который считает всё что нужно бизнесу». Открываете SQL — 300 строк без единого отступа, всё в нижнем регистре, алиасы типа a, b, c2.

Такое наследство разбирают неделями.

Правила хорошего SQL-форматирования

Единого стандарта нет — это как в Python: есть PEP 8, но у каждой команды своя версия. Вот правила которые приняты в большинстве команд:

1. Ключевые слова — ВЕРХНИЙ РЕГИСТР

-- Плохо
select id from users where active = true;

-- Хорошо
SELECT id FROM users WHERE active = true;

Это не просто красота. Когда SELECT, FROM, WHERE, JOIN выделяются визуально, структура запроса считывается с первого взгляда.

2. Каждое предложение — с новой строки

-- Плохо
SELECT id, name FROM users WHERE id > 100 ORDER BY name;

-- Хорошо
SELECT id, name
FROM users
WHERE id > 100
ORDER BY name;

3. Список колонок — каждая на своей строке с выравниванием

-- Плохо
SELECT user_id, order_date, SUM(amount) AS total, COUNT(*) AS cnt FROM orders GROUP BY user_id, order_date;

-- Хорошо
SELECT
user_id,
order_date,
SUM(amount) AS total,
COUNT(*) AS cnt
FROM orders
GROUP BY user_id, order_date;

Выравнивание AS по одному столбцу — опционально, но резко улучшает читаемость когда колонок много.

4. JOIN — явно и с отступом

-- Плохо (неявный JOIN)
SELECT * FROM orders o, users u WHERE o.user_id = u.id;

-- Хорошо
SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id;

5. WHERE с AND/OR — выравнивание по ключевому слову

-- Плохо
WHERE status = 'active' AND created_at > '2024-01-01' AND amount > 1000

-- Хорошо
WHERE status = 'active'
AND created_at > '2024-01-01'
AND amount > 1000

AND/OR в начале строки — спорное правило, но большинство современных форматтеров делают именно так.

6. CTE — с явными отступами и комментарием

-- Хорошо
WITH
-- Активные пользователи за последние 30 дней
active_users AS (
SELECT user_id
FROM sessions
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY user_id
),

-- Их первая покупка
first_orders AS (
SELECT
o.user_id,
MIN(o.created_at) AS first_order_at
FROM orders o
JOIN active_users au ON au.user_id = o.user_id
GROUP BY o.user_id
)

SELECT *
FROM first_orders
ORDER BY first_order_at DESC;

7. Подзапросы — с отступом 4 пробела

SELECT *
FROM (
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id
) ranked
WHERE total > 10000;

Что форматтер делает за вас автоматически

Форматировать вручную — потеря времени. Хороший SQL форматтер за секунду:

  • Приводит ключевые слова к ВЕРХНЕМУ РЕГИСТРУ
  • Расставляет переносы строк между предложениями
  • Выносит каждую колонку на отдельную строку
  • Выравнивает AS
  • Расставляет отступы в подзапросах и CTE
  • Форматирует AND/OR в одном стиле

Попробуйте вставить любой ваш запрос в SQL Formatter на sqllab.ru — форматирует мгновенно, поддерживает PostgreSQL, MySQL, SQLite, BigQuery, без регистрации.

Разбор реальных случаев: до и после

Случай 1: простой аналитический запрос

До:

select category,count(*) as cnt,sum(revenue) as total_revenue,avg(revenue) as avg_revenue from sales where date_trunc('month',sale_date)=date_trunc('month',current_date) and status!='cancelled' group by category order by total_revenue desc

После:

SELECT
category,
COUNT(*) AS cnt,
SUM(revenue) AS total_revenue,
AVG(revenue) AS avg_revenue
FROM sales
WHERE DATE_TRUNC('month', sale_date) = DATE_TRUNC('month', CURRENT_DATE)
AND status != 'cancelled'
GROUP BY category
ORDER BY total_revenue DESC;

Размер одинаковый — но второй читается за 5 секунд, первый за 30.

Случай 2: CTE с несколькими шагами

До:

with cohort as (select user_id, date_trunc('month',min(created_at)) as cohort_month from orders group by user_id), activity as (select o.user_id, date_trunc('month',o.created_at) as activity_month from orders o join cohort c on c.user_id=o.user_id) select cohort_month, activity_month, count(distinct user_id) as users from activity group by 1,2 order by 1,2

После:

WITH cohort AS (
SELECT
user_id,
DATE_TRUNC('month', MIN(created_at)) AS cohort_month
FROM orders
GROUP BY user_id
),

activity AS (
SELECT
o.user_id,
DATE_TRUNC('month', o.created_at) AS activity_month
FROM orders o
JOIN cohort c ON c.user_id = o.user_id
)

SELECT
cohort_month,
activity_month,
COUNT(DISTINCT user_id) AS users
FROM activity
GROUP BY 1, 2
ORDER BY 1, 2;

В исходной версии даже не сразу видно что здесь два CTE — они сливаются в одну строку.

Случай 3: оконные функции

До:

select id,user_id,amount,row_number() over(partition by user_id order by created_at) as rn,lag(amount) over(partition by user_id order by created_at) as prev_amount,sum(amount) over(partition by user_id order by created_at rows between unbounded preceding and current row) as running_total from orders

После:

SELECT
id,
user_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at
) AS rn,
LAG(amount) OVER (
PARTITION BY user_id
ORDER BY created_at
) AS prev_amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;

Оконные функции особенно выигрывают от форматирования — PARTITION BY, ORDER BY, ROWS BETWEEN теперь читаются как структура, а не каша.

Форматирование в разных диалектах SQL

Инструмент важно выбирать с учётом диалекта — у разных баз есть специфический синтаксис.

-2

SQL Formatter на sqllab.ru поддерживает все четыре диалекта — можно выбрать нужный и получить корректный результат без ручных правок.

Как встроить форматирование в рабочий процесс

В IDE

  • DataGrip — встроенный форматтер: Ctrl+Alt+L / Cmd+Alt+L
  • VS Code + расширение SQL Formatter — форматирует при сохранении
  • DBeaver — Ctrl+Shift+F

В code review

Можно добавить проверку форматирования в CI через sqlfluff:

pip install sqlfluff
sqlfluff lint my_query.sql --dialect postgres
sqlfluff fix my_query.sql --dialect postgres

Онлайн, быстро

Если нет времени настраивать окружение — sqllab.ru/tools/formatter. Вставил, скопировал, пошёл дальше.

Когда форматирование не поможет

Форматтер выравнивает отступы — но не исправляет логику. Вот примеры где красивый запрос всё равно неверен:

-- Красиво, но баг: LEFT JOIN + WHERE убивает NULL-строки
SELECT
u.name,
o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'; -- ← превращает LEFT в INNER JOIN!

-- Правильно
SELECT
u.name,
o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
AND o.status = 'paid';

-- Красиво, но деление на ноль в edge-case
SELECT
category,
SUM(revenue) / COUNT(orders) AS avg_order -- ← упадёт если COUNT = 0
FROM sales
GROUP BY category;

-- Правильно
SELECT
category,
SUM(revenue) / NULLIF(COUNT(orders), 0) AS avg_order
FROM sales
GROUP BY category;

Для проверки логических ошибок есть отдельный инструмент — SQL Linter, который ловит 12 антипаттернов включая оба примера выше.

Главное

Форматирование SQL — это профессиональная привычка, которая:

  • экономит время на код-ревью
  • ускоряет дебаггинг
  • помогает самому автору думать яснее

Хорошее правило: если ваш запрос читается дольше 15 секунд — отформатируйте его. Не для других — для себя.

Попробовать форматтер → sqllab.ru/tools/formatter