MS SQL запросы: как писать SELECT, WHERE, GROUP BY и JOIN

MS SQL запросы: как писать SELECT, WHERE, GROUP BY и JOIN Полезное

MS SQL запрос — это команда к базе данных Microsoft SQL Server, написанная на языке T-SQL (диалект стандартного SQL). Запрос описывает, какие данные выбрать, изменить или сгруппировать, а СУБД сама решает, как их получить. Основа повседневной работы — запрос выборки SELECT.

Ниже разберем структуру SELECT, фильтрацию через WHERE, группировку с агрегатами (GROUP BY, HAVING), сортировку ORDER BY и соединение таблиц JOIN — на примерах, прогнанных в песочнице. Отдельно отметим, где синтаксис T-SQL отличается от переносимого стандарта.

Из чего состоит запрос SELECT

Запрос выборки собирается из предложений (clauses), которые пишутся в фиксированном порядке. Обязательны только два: SELECT (какие столбцы вернуть) и FROM (из какой таблицы). Остальные добавляются по необходимости.

  • SELECT — список столбцов или * для всех столбцов.
  • FROM — источник данных (таблица или соединение таблиц).
  • WHERE — условие отбора строк до группировки.
  • GROUP BY — группировка строк для агрегатов.
  • HAVING — условие отбора уже сгруппированных строк.
  • ORDER BY — сортировка результата.

Важно не путать порядок записи с порядком выполнения. Записываем SELECT первым, а СУБД логически обрабатывает запрос иначе: FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY. Из-за этого, например, псевдоним столбца из SELECT не виден в WHERE, но виден в ORDER BY.

Готовим таблицы и делаем первую выборку

Все примеры работают на двух таблицах: клиенты и их заказы. Приведенный ниже код переносимый — он одинаково выполнится и в SQL Server, и в других СУБД. Мы прогнали его в песочнице SQLite; в SQL Server те же запросы дадут те же строки.

CREATE TABLE customers (id INTEGER, name TEXT, city TEXT);
INSERT INTO customers VALUES (1,'Anna','Moskva'),(2,'Boris','Kazan'),(3,'Vera','Moskva'),(4,'Gleb','Kazan');

CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount INTEGER, status TEXT);
INSERT INTO orders VALUES (1,1,1200,'paid'),(2,1,800,'paid'),(3,2,500,'new'),(4,3,2000,'paid'),(5,3,300,'paid'),(6,4,1500,'cancelled'),(7,1,400,'new');

SELECT id, amount, status FROM orders WHERE status = 'paid' AND amount >= 1000 ORDER BY amount DESC;

Результат последнего SELECT (оплаченные заказы на сумму от 1000, по убыванию):

id | amount | status
4  | 2000   | paid
1  | 1200   | paid

Запрос вернул только строки, где выполнены оба условия WHERE (статус paid и сумма не меньше 1000), а ORDER BY ... DESC отсортировал их от большей суммы к меньшей.

WHERE: фильтрация строк

WHERE отбирает строки по условию до всякой группировки. Внутри условия используют сравнения и логические связки. Полезно помнить набор операторов, который закрывает почти все бытовые фильтры.

  • Сравнения: =, <> (не равно), <, >, <=, >=.
  • AND — выполнены все условия, OR — хотя бы одно, NOT — отрицание.
  • IN (список) — значение входит в перечень; NOT IN — не входит.
  • BETWEEN a AND b — значение в диапазоне включительно.
  • LIKE 'шаблон' — совпадение по маске: % заменяет любое число символов, _ — ровно один.
  • IS NULL / IS NOT NULL — проверка на отсутствие значения.

Про NULL отдельная оговорка: это не ноль и не пустая строка, а «значение отсутствует». Поэтому amount = NULL всегда ложно — для проверки нужен именно IS NULL. Это одна из самых частых ошибок новичка.

INSERT, UPDATE, DELETE: изменение данных

Выборка только читает данные. Для записи есть три команды: INSERT добавляет строки, UPDATE меняет значения в существующих, DELETE удаляет. У UPDATE и DELETE почти всегда нужен WHERE — без него изменится или удалится вся таблица.

CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount INTEGER, status TEXT);
INSERT INTO orders VALUES (1,1,1200,'paid'),(2,1,800,'paid'),(3,2,500,'new');

INSERT INTO orders (id, customer_id, amount, status) VALUES (4, 2, 900, 'new');
UPDATE orders SET status = 'paid' WHERE id = 3;

SELECT id, customer_id, amount, status FROM orders ORDER BY id;

Состояние таблицы после вставки и обновления:

id | customer_id | amount | status
1  | 1           | 1200   | paid
2  | 1           | 800    | paid
3  | 2           | 500    | paid
4  | 2           | 900    | new

Заказ с id = 4 добавился, а у заказа id = 3 статус сменился с new на paid. Указывать список столбцов в INSERT (как во второй вставке) — хорошая привычка: запрос не сломается, если в таблицу позже добавят столбец.

GROUP BY, агрегаты и HAVING

Агрегатные функции сворачивают набор строк в одно значение. Их пять основных, они одинаковы в стандарте и в T-SQL.

Функция Что возвращает
COUNT() число строк
SUM() сумму значений столбца
AVG() среднее значение
MIN() / MAX() минимум и максимум

GROUP BY разбивает строки на группы по значению столбца, и агрегат считается внутри каждой группы. HAVING фильтрует уже готовые группы — это и есть его отличие от WHERE: WHERE отбирает строки до группировки и не умеет работать с агрегатами, а HAVING отбирает группы по результату агрегата.

CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount INTEGER, status TEXT);
INSERT INTO orders VALUES (1,1,1200,'paid'),(2,1,800,'paid'),(3,2,500,'new'),(4,3,2000,'paid'),(5,3,300,'paid'),(6,4,1500,'cancelled'),(7,1,400,'new');

SELECT customer_id, COUNT(*) AS orders_cnt, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 1000
ORDER BY total DESC;

Сумма и число заказов по каждому клиенту, оставили только тех, у кого суммарно больше 1000:

customer_id | orders_cnt | total
1           | 3          | 2400
3           | 2          | 2300
4           | 1          | 1500

Клиент с customer_id = 2 в результат не попал: у него один заказ на 500, а HAVING SUM(amount) > 1000 отсеял всю его группу. Ключевое слово AS задает псевдоним столбца (orders_cnt, total) — результат становится читабельнее.

Правило про GROUP BY: каждый столбец из SELECT, к которому не применяется агрегат, обязан присутствовать в GROUP BY. Если это нарушить, SQL Server вернет ошибку. Здесь диалекты расходятся: SQLite и старый MySQL могут молча вернуть произвольное значение вместо ошибки — поэтому проверять такие запросы стоит на целевой СУБД.

JOIN: соединение таблиц

JOIN собирает строки из двух таблиц по совпадению ключа, заданного в ON. Чаще всего связывают внешний ключ одной таблицы (orders.customer_id) с первичным ключом другой (customers.id). Типы соединений различаются тем, что делать со строками без пары.

Тип Что возвращает
INNER JOIN только строки, у которых есть пара в обеих таблицах
LEFT JOIN все строки левой таблицы плюс совпавшие из правой (иначе NULL)
RIGHT JOIN все строки правой таблицы плюс совпавшие из левой
FULL JOIN все строки обеих таблиц, совпавшие соединены

Сначала INNER JOIN: посчитаем, сколько потратил каждый клиент по оплаченным заказам. Клиенты без оплаченных заказов в результат не попадут — в этом суть внутреннего соединения.

CREATE TABLE customers (id INTEGER, name TEXT, city TEXT);
INSERT INTO customers VALUES (1,'Anna','Moskva'),(2,'Boris','Kazan'),(3,'Vera','Moskva'),(4,'Gleb','Kazan');
CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount INTEGER, status TEXT);
INSERT INTO orders VALUES (1,1,1200,'paid'),(2,1,800,'paid'),(3,2,500,'new'),(4,3,2000,'paid'),(5,3,300,'paid'),(6,4,1500,'cancelled'),(7,1,400,'new');

SELECT c.name, c.city, SUM(o.amount) AS spent
FROM customers AS c
INNER JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.name, c.city
ORDER BY spent DESC;

Результат — только клиенты с оплаченными заказами:

name | city   | spent
Vera | Moskva | 2300
Anna | Moskva | 2000

Борис (только заказ в статусе new) и Глеб (только cancelled) выпали: у них нет ни одной строки, прошедшей условие WHERE o.status = 'paid'. Псевдонимы таблиц (c, o) сокращают запись и обязательны, когда столбцы с одинаковыми именами есть в обеих таблицах.

Теперь LEFT JOIN на тех же данных с добавленным клиентом без заказов. Он показывает противоположное поведение: строки левой таблицы сохраняются, даже если пары нет.

CREATE TABLE customers (id INTEGER, name TEXT, city TEXT);
INSERT INTO customers VALUES (1,'Anna','Moskva'),(2,'Boris','Kazan'),(3,'Vera','Moskva'),(4,'Gleb','Kazan'),(5,'Dina','Moskva');
CREATE TABLE orders (id INTEGER, customer_id INTEGER, amount INTEGER, status TEXT);
INSERT INTO orders VALUES (1,1,1200,'paid'),(2,1,800,'paid'),(3,2,500,'new'),(4,3,2000,'paid'),(5,3,300,'paid'),(6,4,1500,'cancelled'),(7,1,400,'new');

SELECT c.name, COUNT(o.id) AS orders_cnt
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY orders_cnt DESC, c.name;

Число заказов у каждого клиента, включая тех, у кого их нет:

name  | orders_cnt
Anna  | 3
Vera  | 2
Boris | 1
Gleb  | 1
Dina  | 0

Дина осталась в выборке с orders_cnt = 0, хотя заказов у нее нет. Тонкость: считать нужно COUNT(o.id), а не COUNT(*) — иначе несуществующая строка правой таблицы посчиталась бы за единицу, и у Дины было бы 1.

Где T-SQL отличается от стандарта

Разобранные выше запросы — переносимое ядро SQL. Но у T-SQL есть свой синтаксис для типовых задач, который в других СУБД не сработает. Эти конструкции нужно помечать как специфичные для SQL Server.

Задача Стандарт / SQLite T-SQL (SQL Server)
Ограничить число строк LIMIT 3 TOP 3 или OFFSET ... FETCH
Текущие дата и время CURRENT_TIMESTAMP GETDATE(), SYSDATETIME()
Автоинкремент ключа (свой в каждой СУБД) IDENTITY(1,1)
Обработка ошибок нет в стандарте TRY ... CATCH

Например, «первые три самых крупных заказа» в SQL Server пишут через TOP: SELECT TOP 3 id, amount FROM orders ORDER BY amount DESC. В SQLite или PostgreSQL тот же результат дает LIMIT 3 в конце запроса. Начиная с SQL Server 2012, доступна и переносимая стандартная форма ORDER BY amount DESC OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY.

Отдельная частая ловушка — конкатенация строк. В стандарте (SQLite, PostgreSQL, Oracle) строки склеивают двумя вертикальными чертами, а в T-SQL для этого используют оператор + или функцию CONCAT(). Прямой перенос такого выражения между СУБД сломается.

Если запрос не работает: частые симптомы

  • Ошибка вида «column … is invalid in the select list because it is not contained in … GROUP BY» — в SELECT есть столбец без агрегата, не указанный в GROUP BY. Добавьте его в GROUP BY или оберните агрегатом.
  • Запрос выполняется, но строк меньше, чем ждали — обычно виноват INNER JOIN или условие WHERE, отсекающее строки без пары. Замените на LEFT JOIN или проверьте условие.
  • Фильтр по NULL ничего не находит — вместо = NULL нужно IS NULL.
  • HAVING ругается на незнакомый столбец — в HAVING можно ссылаться только на сгруппированные столбцы и агрегаты, для остального используйте WHERE.

Выводы

  • MS SQL запрос — команда к SQL Server на языке T-SQL; основа выборки — SELECT ... FROM, к которым по мере надобности добавляют WHERE, GROUP BY, HAVING, ORDER BY.
  • Порядок выполнения отличается от порядка записи: FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY; отсюда правила видимости псевдонимов и разница WHERE и HAVING.
  • WHERE фильтрует строки до группировки, HAVING — готовые группы по агрегату; NULL проверяют через IS NULL, а не =.
  • JOIN соединяет таблицы по ключу: INNER оставляет только пары, LEFT сохраняет все строки левой таблицы; при подсчете по LEFT JOIN считайте COUNT(столбец), а не COUNT(*).
  • Ядро запросов переносимо между СУБД, а TOP, GETDATE(), IDENTITY, TRY ... CATCH — специфика T-SQL и в других базах не работают.

Где применяется и связь с практикой

Запросы SELECT с фильтрами, группировками и соединениями — это ежедневная работа разработчика, аналитика и администратора баз данных: отчеты, выгрузки, проверка данных, бизнес-логика в хранимых процедурах. Умение читать порядок выполнения запроса и правильно выбирать тип JOIN напрямую влияет и на корректность результата, и на скорость.

Освойте тему на практике

Если хотите разобрать T-SQL и SQL Server системно — от запросов и соединений до хранимых процедур, оптимизации и планов выполнения — посмотрите курс MS SQL Server Разработчик. Оценить формат и уровень до старта помогают бесплатные открытые уроки Otus — живые занятия с преподавателями.

Смежные темы: Введение в SQL: что требуется знать новичку.

FAQ

Чем WHERE отличается от HAVING? WHERE отбирает отдельные строки до группировки и не работает с агрегатами. HAVING отбирает уже сгруппированные строки по результату агрегата (например, SUM(amount) > 1000). Если группировки нет, обходятся одним WHERE.

Что вернет запрос, если у строки в столбце NULL, а я фильтрую по равенству? Ничего: сравнение с NULL через = всегда дает неопределенность, которая трактуется как ложь. Для отбора таких строк нужен IS NULL, для исключения — IS NOT NULL.

Можно ли перенести запрос из SQL Server в MySQL или PostgreSQL? Ядро (SELECT, WHERE, GROUP BY, JOIN, агрегаты) переносится почти без правок. А специфичные для T-SQL конструкции — TOP, GETDATE(), IDENTITY, TRY ... CATCH — придется заменить на аналоги целевой СУБД.

OTUS Журнал