Предыдущая лекция |   | Следующая лекция :----------------:|:----------:|:----------------: [Основы SQL](./sql_for_beginner.md) | [Содержание](../readme.md#проектирование-баз-данных) | [Создание ER-диаграммы](./5_1_1_1_erd_workbench.md) # Группировка, агрегатные функции, JOIN-ы ## Группировка, оператор GROUP BY >Взято [отсюда](https://sql-academy.org/ru/guide/groupping) Давайте выполним запрос: ```sql SELECT id, home_type, has_tv, price FROM Rooms; ``` id | home_type | has_tv | price :--:|--------------:|:---------:|:-------: 1 | Private room | 1 | 149 2 | Entire home/apt | 0 | 225 3 | Private room | 1 | 150 4 | Entire home/apt | 1 | 89 5 | Entire home/apt | 0 | 80 Так мы получили информацию по каждому сдаваемому жилому помещению. А что если мы хотим получить информацию не о каждой записи отдельно, а о группах, которые они образуют? Например, такими группами могут выступать записи, разбитые по типу жилья: * Shared room (аренда комнаты на несколько человек) * Private room (аренда целой комнаты) * Entire home/apt (аренда целой квартиры) Эти группы включают разные записи в таблице и, соответственно, обладают разными характеристиками, которые нам могут быть весьма полезны. Такой полезной информацией о группах может быть: * средняя стоимость аренды комнаты или целого жилого помещения * количество сдаваемых жилых помещений каждого типа Для ответов на все эти и многие другие вопросы есть оператор `GROUP BY`. ### Общая структура запроса с GROUP BY ```sql SELECT [литералы, агрегатные_функции, поля_группировки] FROM имя_таблицы GROUP BY поля_группировки; ``` Для того, чтобы записи у нас образовали группы по типу жилья, мы должны после `GROUP BY` указать `home_type`, т.е. поле, по которому будет происходить группировка. ```sql SELECT home_type FROM Rooms GROUP BY home_type ```
home_type
Private room
Entire home/apt
Shared room
>Следует иметь в виду, что для `GROUP BY` все значения `NULL` трактуются как равные, т.е. при группировке по полю, содержащему NULL-значения, все такие строки попадут в одну группу При использовании оператора `GROUP BY` мы перешли от работы с отдельными записями на работу с образовавшимися группами. В связи с этим мы не можем просто вывести любое поле из записи (например, `has_tv` или `price`), как мы это могли делать раньше. Так как в каждой группе может быть несколько записей и в каждой из них в этом поле может быть разное значение. При использовании `GROUP BY` мы можем выводить только: * литералы, т.е. указанное явным образом фиксированные значения. Мы можем их выводить, так как это фиксированные значения, которые ни от чего не зависят. Например, ```sql SELECT home_type, 'literal' FROM Rooms GROUP BY home_type ``` home_type | literal ------------|----------- Private room | literal Entire home/apt | literal Shared room | literal * результаты агрегатных функций, т.е. вычисленные значения на основании набора значений. Более детальную информацию об агрегатных функциях мы затронем ниже. Но для примера рассмотрим агрегатную функцию `AVG`. Функция `AVG` принимает в качестве аргумента название поля, по которому мы хотим вычислить среднее значение для каждой группы. ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms GROUP BY home_type ``` home_type | avg_price ------------|------------- Private room | 89.4286 Entire home/apt | 148.6667 Shared room | 40 Так выполненный запрос сначала разбивает все записи из таблицы `Rooms` на три группы, опираясь на поле `home_type`. Далее, для каждой группы суммирует все значения, взятые из поля `price` у каждой записи, входящей в текущую группу, и затем полученный результат делится на количество записей в данной группе. * поля группировки. Мы можем их выводить, так как в рамках одной группы поля, по которым осуществлялась группировка, одинаковые. ### Группировка по 2 и более полям Мы уже рассмотрели как записи в таблице группируются по одному полю. Для дополнительной иллюстрации это выглядит примерно так, когда поле группировки `home_type`: ![](../img/groupping_by_1_field.webp) При группировке по двум и более полям принцип остается такой же, только теперь образовавшиеся группы дополнительно разбиваются на более мелкие группы в зависимости от второго поля группировки. Пример группировки по `home_type` и `has_tv`: ![](../img/groupping_by_2_field.webp) ## Агрегатные функции Выше мы обсудили, что при использовании оператора `GROUP BY` мы можем использовать агрегатные функции. Давайте поговорим о них подробнее. >Агрегатная функция – это функция, которая выполняет вычисление на наборе значений и возвращает одиночное значение. Например, запрос с использованием агрегатной функции `AVG` может выглядеть так: ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms GROUP BY home_type ``` home_type | avg_price ------------|:------------: Private room | 89.4286 Entire home/apt | 148.6667 Shared room | 40 ### Описание агрегатных функций * `SUM(поле_таблицы)` - Возвращает сумму значений * `AVG(поле_таблицы)` - Возвращает среднее значение * `COUNT(поле_таблицы)` - Возвращает количество записей * `MIN(поле_таблицы)` - Возвращает минимальное значение * `MAX(поле_таблицы)` - Возвращает максимальное значение >Агрегатные функции применяются для значений, не равных `NULL`. Исключением является функция `COUNT(*)`. ### Примеры * Найдём количество каждого вида жилья и отсортируем полученный список по убыванию: ```sql SELECT home_type, COUNT(*) as amount FROM Rooms GROUP BY home_type ORDER BY amount DESC ``` home_type | amount ------------|:---: Private room | 28 Entire home/apt | 21 Shared room | 1 * Для каждого жилого помещения найдём самую позднюю дату выезда (поле `end_date`) ```sql SELECT room_id, MAX(end_date) AS last_end_date FROM Reservations GROUP BY room_id ``` room_id | last_end_date --------|--- 1 | 2019-02-04T12:00:00.000Z 2 | 2020-03-23T09:00:00.000Z 13 | 2020-04-21T10:00:00.000Z 16 | 2019-06-24T10:00:00.000Z ## Оператор HAVING Мы уже рассматривали запрос получения средней стоимости аренды жилых помещений в зависимости от типа жилья: ![](../img/having01.png) ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms GROUP BY home_type ``` home_type | avg_price ------------|:------------: Private room | 89.4286 Entire home/apt | 148.6667 Shared room | 40 Давайте доработаем этот запрос таким образом, чтобы в итоговой выборке отображались только те группы, чья средняя стоимость больше `50`. Обладая предыдущим опытом, есть большой соблазн попытаться использовать для этих целей оператор `WHERE`. Но при попытке выполнить такой запрос СУБД неминуемо выдаст ошибку, указав, что мы некорректно используем синтаксис `WHERE avg_price > 50`. ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms GROUP BY home_type WHERE avg_price > 50 ``` Говоря наперёд, для фильтрации групп мы должны использовать оператор `HAVING` (который применяется к готовой выборке): ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms GROUP BY home_type HAVING avg_price > 50 ``` home_type | avg_price ------------|:---: Private room | 89.4286 Entire home/apt | 148.6667 ### Порядок выполнения SQL запроса Но почему же мы не могли использовать WHERE, и зачем нужен отдельный оператор для этой цели? Все дело в порядке выполнения SQL запроса. ![](../img/sql_query_order_ru.webp) Наш первый запрос был неверный, потому что мы пытались использовать поле `avg_price` у образовавшихся групп ещё до их образования, так как выполнение оператора `WHERE` предшествует группировке. То есть оператор `WHERE` в момент его исполнения ничего не знает о последующей группировке, он работает только с записями из таблицы. Так мы, например, с его помощью можем отфильтровать записи таблицы `Rooms` по цене до применения группировки и лишь затем вычислить среднюю стоимость групп оставшегося жилья: ```sql SELECT home_type, AVG(price) as avg_price FROM Rooms WHERE price > 50 GROUP BY home_type ``` home_type | avg_price ------------|:---: Private room | 96.875 Entire home/apt | 148.6667 >Но если присмотреться к результату, то видно, что средняя цена получилась завышенной. И это очевидно, так как мы просто выкинули низкие цены. ### Пример использования HAVING Для примера давайте получим минимальную стоимость каждого типа жилья c телевизором. При этом нас интересуют только типы жилья, содержащие как минимум пять жилых помещений, относящихся к ним. Чтобы получить такой результат, мы должны: * Сначала получить все данные из таблицы ```sql SELECT ... FROM Rooms; ``` * Затем выбрать из всех записей таблицы `Room` только интересующие нас, т.е. только жильё с телевизором ```sql SELECT ... FROM Rooms WHERE has_tv = True ``` * Затем сгруппировать данные записи о жилых помещениях по их типу ```sql SELECT ... FROM Rooms WHERE has_tv = True GROUP BY home_type ``` * После этого отфильтровать полученные группы по условию. Нас интересуют группы, имеющие как минимум 5 представителей ```sql SELECT ... FROM Rooms WHERE has_tv = True GROUP BY home_type HAVING COUNT(*) >= 5 ``` * И под конец посмотреть, что нас просят в задании и, соответственно, добавить вывод необходимой информации. В нашем случае, нам необходимо вывести название типа жилья и его минимальную стоимость. ```sql SELECT home_type, MIN(price) as min_price FROM Rooms WHERE has_tv = True GROUP BY home_type HAVING COUNT(*) >= 5; ``` ## Многотабличные запросы, JOIN ### Многотабличные запросы В предыдущих статьях описывалась работа только с одной таблицей базы данных. В реальности же очень часто приходится делать выборку из нескольких таблиц, каким-то образом объединяя их. В данной статье вы узнаете основные способы соединения таблиц. Например, если мы хотим получить информацию о тратах на покупки, мы можем её получить следующим образом: ```sql SELECT family_member, amount * unit_price AS price FROM Payments ``` family_member | price ---|:---: 1 | 2000 2 | 2100 3 | 100 4 | 350 В поле `family_member` полученной выборки отображаются идентификаторы записей из таблицы `Payments`, но для нас они мало что значат. Вместо этих идентификаторов было бы гораздо нагляднее выводить имена тех, кто покупал (поле `member_name` из таблицы `FamilyMember`). Ровно для этого и существует объединение таблиц и оператор `JOIN`. ![](../img/join01.png) ### Общая структура многотабличного запроса ```sql SELECT поля_таблиц FROM таблица_1 [INNER] | [[LEFT | RIGHT | FULL][OUTER]] JOIN таблица_2 ON условие_соединения [[INNER] | [[LEFT | RIGHT | FULL][OUTER]] JOIN таблица_n ON условие_соединения] ``` Как можно увидеть по структуре, соединение бывает: * внутренним `INNER` (по умолчанию) * внешним `OUTER`, при этом внешнее соединение делится на левое `LEFT`, правое `RIGHT` и полное `FULL` С более подробными деталями, чем отличается внутреннее соединение от внешнего и как они работают, мы познакомимся в следующих статьях. Пока нам достаточно лишь знать, что для вышеописанного примера с запросом на покупки нам понадобится именно запрос с внутренним соединением, который будет выглядеть следующим образом: ```sql SELECT family_member, member_name, amount * unit_price AS price FROM Payments JOIN FamilyMembers ON Payments.family_member = FamilyMembers.member_id ``` family_member | member_name | price :---:|---|:---: 1 | Headley Quincey | 2000 2 | Flavia Quincey | 2100 3 | Andie Quincey | 100 4 | Lela Quincey | 350 В данном запросе мы сопоставляем записи из таблицы `Payments` и записи из таблицы `FamilyMembers`. Чтобы сопоставление работало, мы указываем как именно записи из двух разных таблиц должны находить друг друга. Это условие указывается после `ON`: ```sql ON Payments.family_member = FamilyMembers.member_id ``` В нашем случае поле `family_member` указывает на идентификатор в таблице `FamilyMembers` и таким образом помогает однозначному сопоставлению. >В большинстве случаев условием соединения является равенство столбцов таблиц (таблица_1.поле = таблица_2.поле), однако точно так же можно использовать и другие операторы сравнения. ### Вывод всех столбцов из таблицы в многотабличном запросе Ранее, когда все запросы выполнялись на одной таблице, было достаточно указать символ `*`, чтобы вывести все поля из этой таблицы. Теперь же, когда таблиц может быть несколько, `*` будет означать "вывести все столбцы из таблиц, перечисленных в выражении `FROM`". В некоторых случаях нам может понадобиться вывести столбцы, принадлежащие только какой-то конкретной таблице. Например, дано соединение таблиц `Payments` и `FamilyMembers`, и в итоговую выборку необходимо вывести только поля из таблицы `FamilyMembers`. Как это сделать? Всё очень просто! Необходимо перед символом `*` добавить название таблицы: ```sql SELECT FamilyMembers.* FROM Payments JOIN FamilyMembers ON Payments.family_member = FamilyMembers.member_id ``` Таким же образом можно вывести все столбцы из нескольких таблиц: ```sql SELECT Payments.*, FamilyMembers.* FROM Payments JOIN FamilyMembers ON Payments.family_member = FamilyMembers.member_id ``` >В данном случае вместо `Payments.*`, `FamilyMembers.*` можно использовать `*`, т.к. в `FROM` перечислены только эти две таблицы. Вывод будет одинаков в обоих случаях. А что, если нужно вывести **несколько столбцов из одной таблицы и все из другой**? Это тоже возможно! Выведем поля `payment_id` и `family_member` из `Payments`, а также все поля из `FamilyMembers`: ```sql SELECT payment_id, family_member, FamilyMembers.* FROM Payments JOIN FamilyMembers ON Payments.family_member = FamilyMembers.member_id ``` ### Псевдонимы для таблиц При работе с большими многотабличными запросами рекомендуется использовать псевдонимы (алиасы) для таблиц. Это не только улучшает читаемость кода, но и помогает избежать ошибок в сложных запросах. Псевдонимы задаются после имени таблицы с помощью ключевого слова `AS`: ```sql SELECT id, name FROM Passenger AS pass ``` Теперь обращаться к столбцам таблицы можно через псевдоним: ```sql SELECT pass.id, pass.name FROM Passenger AS pass ``` >Ключевое слово AS является опциональным, как и у псевдонимов столбцов. Рассмотрим пример запроса с несколькими таблицами. Допустим, нам нужно вывести идентификаторы и имена пассажиров, которые хотя бы раз совершали перелёт: ```sql SELECT pass.id, pass.name FROM Passenger AS pass JOIN Pass_in_trip AS pit ON pit.passenger = pass.id ``` В этом примере таблицам `Passenger` и `Pass_in_trip` присваиваются псевдонимы `pass` и `pit`, соответственно, а дальше используются для вывода столбцов из этих таблиц. Обратите внимание, что в обеих таблицах есть поле с одинаковым названием – `id`. Если вы не укажете из какой таблицы его нужно брать, то СУБД сделает это за вас и отдаст значение из последней таблицы в цепочке `JOIN`. В данном случае это `Pass_in_trip`. При использовании псевдонимов всегда руководствуйтесь следующими простыми правилами, и тогда ваши запросы будут лаконичными и понятными: * Используйте логичные сокращения (например, первые буквы названия таблицы) * Избегайте слишком коротких (однобуквенных) или неочевидных псевдонимов ## Внешнее соединение OUTER JOIN Внешнее соединение может быть трёх типов: левое (`LEFT`), правое (`RIGHT`) и полное (`FULL`) (MySQL не поддерживает полное соединение). Главным отличием внешнего соединения от внутреннего является то, что оно обязательно возвращает все строки одной (`LEFT`, `RIGHT`) или двух таблиц (`FULL`). ### Внешнее левое соединение (LEFT OUTER JOIN) Соединение, которое возвращает все значения из левой таблицы, соединённые с соответствующими значениями из правой таблицы, если они удовлетворяют условию соединения, или заменяет их на `NULL` в обратном случае. Для примера получим из базы данных расписание звонков, объединённых с соответствующими занятиями в расписании занятий. ![](../img/join02.png) Данные в таблице Timepair (расписание звонков): id | start_pair | end_pair :---:|----|--- 1 | 08:30:00 | 09:15:00 2 | 09:20:00 | 10:05:00 3 | 10:15:00 | 11:00:00 Данные в таблице Schedule (расписание занятий): id | date | class | number_pair | teacher | subject | classroom :---:|---|:---:|:-:|:-:|:-:|:-: 1 | 2019-09-01T00:00:00.000Z | 9 | 1 | 11 | 1 | 47 2 | 2019-09-01T00:00:00.000Z | 9 | 2 | 8 | 2 | 13 3 | 2019-09-01T00:00:00.000Z | 9 | 3 | 4 | 3 | 13 4 | 2019-09-02T00:00:00.000Z | 9 | 1 | 4 | 3 | 13 ```sql SELECT Timepair.id 'timepair.id', start_pair, end_pair, Schedule.id 'schedule.id', date, class, number_pair, teacher, subject, classroom FROM Timepair LEFT JOIN Schedule ON Schedule.number_pair = Timepair.id; ``` timepair.id | start_pair | end_pair | schedule.id | date | class | number_pair | teacher | subject | classroom :-:|--|--|:-:|--|:-:|:-:|:-:|:-:|:-: 1 | 08:30:00 | 09:15:00 | 35 | 2019-09-04T00:00:00.000Z | 8 | 1 | 1 | 11 | 4 1 | 08:30:00 | 09:15:00 | 32 | 2019-09-03T00:00:00.000Z | 8 | 1 | 10 | 10 | 40 1 | 08:30:00 | 09:15:00 | 21 | 2019-08-30T00:00:00.000Z | 8 | 1 | 7 | 9 | 53 6 | 13:40:00 | 14:25:00 | `` | `` | `` | `` | `` | `` | `` В выборку попали все строки из левой таблицы, дополненные данными о занятиях. Примечательно, что в конце таблицы есть строки с полями, заполненными `NULL`. Это те строки, для которых не нашлось соответствующих занятий, однако они присутствуют в левой таблице, поэтому тоже были выведены. ### Внешнее правое соединение (RIGHT OUTER JOIN) Соединение, которое возвращает все значения из правой таблицы, соединённые с соответствующими значениями из левой таблицы, если они удовлетворяют условию соединения, или заменяет их на `NULL` в обратном случае. ### Эмуляция полного соединения в MySQL Поскольку MySQL не поддерживает `FULL OUTER JOIN`, его можно эмулировать с помощью `UNION ALL`: ```sql SELECT * FROM левая_таблица LEFT JOIN правая_таблица ON правая_таблица.ключ = левая_таблица.ключ UNION ALL SELECT * FROM левая_таблица RIGHT JOIN правая_таблица ON правая_таблица.ключ = левая_таблица.ключ WHERE левая_таблица.ключ IS NULL ```