import pandas as pd
import sql
import sqlalchemy as sa
# Настройка вывода Pandas
pd.set_option('display.max_rows', 20)
# Подключение и настройка SQL-магии
%load_ext sql
%config SqlMagic.displaycon = False
%config SqlMagic.autopandas = True
# Подключение к базе данных
connection_url = sa.engine.URL.create(
drivername='mysql+pymysql',
host='localhost',
port=3306,
database='sakila',
username='root',
password='*UHB5rdx',
)
engine = sa.create_engine(connection_url)
%sql engine
# Статус инициализации ячейки
print(f"Pandas ver. {pd.__version__}: порог усечения строк уменьшен до 20")
print(f"SQLAlchemy ver. {sa.__version__}: подключение создано")
print(f"JupySQL ver. {sql.__version__}: подключен через SQLAlchemy Engine")Pandas ver. 3.0.5: порог усечения строк уменьшен до 20
SQLAlchemy ver. 2.0.52: подключение создано
JupySQL ver. 0.11.1: подключен через SQLAlchemy Engine
Что такое подзапрос¶
Подзапрос – это запрос, содержащийся в другой инструкции SQL (содержащей инструкции).
Подзапрос всегда заключен в круглые скобки и обычно выполняется перед содержащей инструкцией. Подобно любому запросу, подзапрос возвращает результирующий набор, который может состоять из:
одной строки с одним столбцом;
нескольких строк с одним столбцом;
нескольких строк с несколькими столбцами.
Тип результирующего набора, возвращаемого подзапросом, определяет как можно его использовать и какие операторы может использовать содержащая инструкция для взаимодействия с данными, возвращаемыми подзапросом.
Когда содержащая инструкция завершает выполнение, данные, возвращенные любыми подзапросами уничтожаются, что делает подзапрос действующим как временная таблица с областью видимости инструкции (это означает, что сервер освобождает всю память, выделенную для результатов подзапроса после выполнения инструкции SQL).
Мы уже видели примеры подзапросов в предыдущих главах, вот еще один простой пример для начала:
%%sql
SELECT customer_id, first_name, last_name
FROM customer
WHERE customer_id = (
SELECT MAX(customer_id)
FROM customer
);В этом примере подзапрос возвращает максимальное значение, найденное в столбце customer_id в таблице customer, а затем содержащая инструкция возвращает данные об этом клиенте.
Если не понимаете что делает подзапрос, можно запустить его сам по себе (без скобок), чтобы увидеть что он возвращает:
%%sql
SELECT MAX(customer_id)
FROM customer;Этот подзапрос возвращает одну строку с одним столбцом, что позволяет использовать ее как одно из выражений в условии равенства (если бы подзапрос вернул две или более строк, его можно было бы сравнивать с чем-то, но он не мог бы быть равным чему-либо; подробнее об этом речь пойдет ниже).
В этом случае мы можем взять значение, возвращенное подзапросом и заменить им правое выражение условия фильтрации в содержащем запросе:
%%sql
SELECT customer_id, first_name, last_name
FROM customer
WHERE customer_id = 599;В этом случае подзапрос полезен, поскольку позволяет получить информацию о клиенте с наибольшем идентификатором в одном запросе вместо поиска максимального значения customer_id c помощью одного запроса, а затем написания второго запроса для получения требуемых данных из таблицы customer.
Как вы увидите, подзапросы полезны и во многих других ситуациях и могут стать одним из самых мощных ваших инструментов при работе с SQL.
Типы подзапросов¶
Наряду с отмеченными ранее различиями в отношении типа результурующего набора, возвращаемого подзапросом (одна строка / столбец; одна строка / несколько столбцов; несколько строк / столбцов) можно использовать для классификации подзапросов другую характеристику.
Одни поздапросы полностью автономные (именуются некоррелированными подзапросами),
В то время как другие ссылаются на столбцы из содержащей инструкции (именуются корреллрованными подзапросами).
Некоррелированные подзапросы¶
Пример, показанный выше в этой главе – некоррелированный подзапрос, который может быть выполнен отдельно и не ссылается ни на что из содержащей инструкции.
Большинство подзапросов, с которыми вы столкнетесь, будут принадлежать этому типу. Помимо того что это некоррелируемый подзапрос, данный пример также возвращает результирующий набор, содержащий только одну строку и один столбец. Этот тип подзапроса известен как скалярный подзапрос и может появляться на любой стороне условия, использующего обычные операторы сравнения (=, <>, <, >, <=, >=).
В следующем примере показано как можно использовать скалярный подзапрос в условии неравенства:
%%sql
SELECT city_id, city
FROM city
WHERE country_id <> (
SELECT country_id
FROM country
WHERE country = 'India'
);Этот запрос возвращает все города, находящиеся не в Индии. Подзапрос возвращает идентификатор страны для Индии, а содержащий его запрос возвращает все города, идентификатор страны которых не равен полученному.
Хотя подзапрос в этом примере довольно прост, фактически подзапросы могут быть настолько сложными, насколько это нужно, и могут использовать любые доступные предложения запроса (select, from, where, group by, having и order by).
Если подзапрос используется в условии равенства, но возвращает более одной строки, то будет получено сообщение об ошибке. Например, если изменим предыдущий запрос так, чтобы подзапрос возвращал все страны, за исключением Индии, то получим сообщение об ошибке:
%%sql
SELECT city_id, city
FROM city
WHERE country_id <> (
SELECT country_id
FROM country
WHERE country <> 'India'
);RuntimeError: (pymysql.err.OperationalError) (1242, 'Subquery returns more than 1 row')
[SQL: SELECT city_id, city
FROM city
WHERE country_id <> (
SELECT country_id
FROM country
WHERE country <> 'India'
);]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
Если выполните подзапрос отдельно, то увидете что он возвращает больше одной строки:
%%sql
SELECT country_id
FROM country
WHERE country <> 'India';Содержащий запрос не выполняется, потому что выражение (country_id) не может быть приравнено к набору выражений (1, 2, 3,... 109). Другими словами, нельзя приравнять одну вещь и набор вещей.
Подзапросы с несколькими строками и одним столбцом¶
Если подзапрос возвращает более одной строки, его нельзя использовать в условии равенства (как показано в предыдущем примере).
Однако, есть четыре дополнительных оператора, которые можно использовать для создания условий с этими типами подзапросов.
Операторы in и not in¶
Хотя проверить на равенство одно значение с набором значений нельзя, можно проверить входит ли конкретное значение в набор.
Следующий пример (пусть и не использующий подзапрос) демонстрирует как создать условие, которое использует оператор in для поиска для значения в наборе значений:
%%sql
SELECT country_id
FROM country
WHERE country IN ('Canada', 'Mexico');Выражение в левой части условия – столбец country, а в правой – набор строк. Оператор in проверяет, входит ли строка из столбца country в этот набор; если да, то условие выполняется и строка добавляется к результирующему набору.
Тот же результат можно получить, использую два условия равенства, например:
%%sql
SELECT country_id
FROM country
WHERE country = 'Canada' OR country = 'Mexico';Хотя такой подход кажется разумным (когда набор содержит только два выражения), легко понять почему одно условие с использованием оператора in предпочтительнее, когда множество содержит десятки (или сотни, тысячи и т.д.) значений.
Хотя иногда набор строк, дат или чисел для использования с одной стороны условия создается вручную, куда чаще такой набор создается с помощью подзапроса, который возвращает одну или несколько строк.
В следующем запросе оператор in используется с подзапросом в правой части условия фильтра, чтобы получить все города, которые находятся в Канаде или Мексике:
%%sql
SELECT city_id, city
FROM city
WHERE country_id IN (
SELECT country_id
FROM country
WHERE country IN ('Canada', 'Mexico')
);Помимо проверки имеется ли некоторое значение в наборе, мы можем проверить обратное используя оператор not in. Вот версия предыдущего запроса, использующая not in вместо in:
%%sql
SELECT city_id, city
FROM city
WHERE country_id NOT IN (
SELECT country_id
FROM country
WHERE country IN ('Canada', 'Mexico')
);Этот запрос находит все города, расположенные не в Канаде или Мексике.
Оператор all¶
В то время как оператор in используется чтобы выяснить имеется ли выражение в наборе выражений, оператор all позволяет сравнивать отдельное значение и каждое значение в наборе.
Чтобы создать такое условие, нужно использовать один из операторов сравнения (=, <>, <, > и т.д.) в сочетании с оператором all. Например, следующий запрос находит всех клиентов, которые никогда не получали фильмы напрокат бесплатно:
%%sql
SELECT first_name, last_name
FROM customer
WHERE customer_id <> ALL (
SELECT customer_id
FROM payment
WHERE amount = 0
);Подзапрос возвращает набор идентификаторов клиентов, которые заплатили 0 долларов за прокат фильма, а содержащий запрос возвращает имена всех клиентов, чьи идентификаторы не указаны в результирующем наборе, возвращенном подзапросом.
Если этот подход кажется вам несколько неуклюжим, вы в хорошей компании: большинство людей предпочли бы сформулировать запрос иначе и избежать использования оператора all. Для иллюстрации – предыдущий запрос дает те же результаты, что и следующий пример, в котором используется оператор not in:
%%sql
SELECT first_name, last_name
FROM customer
WHERE customer_id NOT IN (
SELECT customer_id
FROM payment
WHERE amount = 0
);Выбор того или иного запроса – вопрос вкуса, но я думаю, что большинство людей сочтут версию, которая использует not in более легкой для понимания.
%%sql
SELECT first_name, last_name
FROM customer
WHERE customer_id NOT IN (122, 452, NULL);Вот еще один пример с использованием оператора all, но на этот раз подзапрос находится в предложении having:
%%sql
SELECT customer_id, COUNT(*)
FROM rental
GROUP BY customer_id
HAVING COUNT(*) > ALL (
SELECT COUNT(*)
FROM rental r
INNER JOIN customer c ON r.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
INNER JOIN country co ON ct.country_id = co.country_id
WHERE co.country IN ('United States', 'Mexico', 'Canada')
GROUP BY r.customer_id
);Подзапрос возвращает общее количество прокатов фильмов для каждого клиента в Северной Америке:
%%sql
SELECT COUNT(*)
FROM rental r
INNER JOIN customer c ON r.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
INNER JOIN country co ON ct.country_id = co.country_id
WHERE co.country IN ('United States', 'Mexico', 'Canada')
GROUP BY r.customer_id
ORDER BY COUNT(*) DESC;А содержащий запрос возвращает всех клиентов, общее количество прокатов фильмов у которых превышает значение у любого из североамериканских клиентов:
%%sql
SELECT customer_id, COUNT(*)
FROM rental
GROUP BY customer_id
HAVING COUNT(*) > 45;Суть оператора ALL
ALLALL – это математическая приставка к любому оператору сравнения (>, <, >=, <=, =, <>), которая превращает условие в требование:
Сравнение должно быть верным для каждого элемента списка.
Операторы IN и NOT IN умеют проверять только равенство = или неравенство !=
-- Так НЕЛЬЗЯ! Синтаксическая ошибка:
WHERE total_rentals > IN (10, 20, 45)А с ALL можем использовать знак больше или меньше:
-- А вот так МОЖНО:
WHERE total_rentals > ALL (подзапрос)x > ALL (10, 20, 45)буквально означает:x > 10 AND x > 20 AND x > 45.
То есть x должен быть строго больше максимума из этого списка (то есть x > 45).x <> ALL (10, 20, 45)буквально читается:x != 10 AND x != 20 AND x != 45
То есть x не совпадает ни с одним элементом множества, поэтому математическиx NOT IN (подзапрос)<=>x <> ALL (подзапрос)(абсолютные синонимы).
На реальной практике
Для проверки на невхождение всегда пишут
NOT IN(илиNOT EXISTS), потому что это читается естественнее.А вот ключевое слово
ALLиспользуют только тогда, когда нужно написать> ALL(больше максимального) или< ALL(меньше минимального).
x > ALL (список)→ больше максимального элемента списка.x < ALL (список)→ меньше минимального элемента списка.x = ANY (список)→ то же самое, чтоx IN (список).x <> ALL (список)→ то же самое, чтоx NOT IN (список).
Оператор any¶
Как и оператор all, оператор any позволяет сравнивать значение с членами набора значений. В отличие от all условие, использующее оператор any вычисляется как истинное – как только найдется хотя бы одно выполняющееся сравнение.
В следующем примере выполняется поиск всех клиентов, чьи суммарные платежи за прокат фильмов превышают суммарные платежи всех клиентов в Боливии, Парагвае или Чили:
%%sql
SELECT customer_id, SUM(amount)
FROM payment
GROUP BY customer_id
HAVING SUM(amount) > ANY (
SELECT SUM(p.amount)
FROM payment p
INNER JOIN customer c ON p.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
INNER JOIN country co ON ct.country_id = co.country_id
WHERE co.country IN ('Bolivia', 'Paraguay', 'Chile')
GROUP BY co.country
);Подзапрос возвращает стоимость проката фильмов для всех клиентов в Боливии, Парагвае и Чили:
%%sql
SELECT co.country, SUM(p.amount)
FROM payment p
INNER JOIN customer c ON p.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
INNER JOIN country co ON ct.country_id = co.country_id
WHERE co.country IN ('Bolivia', 'Paraguay', 'Chile')
GROUP BY co.country
ORDER BY SUM(p.amount);А содержащий запрос возвращает всех клиентов, которые израсходовали сумму, превышающую расходы клиентов хотя бы одной из этих стран.
Операторы сравнения с ANY и ALL
ANY и ALL| Конструкция | Логический эквивалент | Cмысл (что ищет) | Примечание |
| > ANY | > MIN | Больше минимального элемента | Достаточно быть больше хотя бы одного |
| < ANY | < MAX | Меньше максимального элемента | Достаточно быть меньше хотя бы одного |
| = ANY | IN | Совпадает хотя бы с одним | Полный аналог оператора IN |
| > ALL | > MAX | Больше максимального | Больше абсолютно каждого элемента |
| < ALL | < MIN | Меньше минимального | Меньше абсолютно каждого элемента |
| <> ALL | NOT IN | Не совпадает ни с одним | Полный аналог оператора NOT IN |
Многостолбцовые подзапросы¶
До сих пор примеры подзапросов в этой главе возвращали один столбец и одну или несколько строк. Однаков в определенных ситуациях можно использовать подзапросы, возвращающие два или более столбцов.
Помочь показать полезность многостолбцовых подзапросов может следующий пример, в котором используется несколько подзапросов с одним столбцом:
SQL Style Guide
SELECT
fa.actor_id,
fa.film_id
FROM film_actor fa
WHERE
fa.actor_id IN (
SELECT actor_id
FROM actor
WHERE last_name = 'MONROE'
)
AND fa.film_id IN (
SELECT film_id
FROM film
WHERE rating = 'PG'
);%%sql
SELECT fa.actor_id, fa.film_id
FROM film_actor fa
WHERE fa.actor_id IN
(SELECT actor_id FROM actor WHERE last_name = 'MONROE')
AND fa.film_id IN
(SELECT film_id FROM film WHERE rating = 'PG');В этом запросе для идентификации всех участников с фамилией MONROE и всех фильмов с рейтингом PG используются два подзапроса, а затем содержащий запрос использует эту информацию для извлечения всех случаев, когда актер с этой фамилией появляется в фильме с рейтингом PG.
Однако можно объединить два подзапроса с одним столбцом в один подзапрос с несколькими столбцами и сравнивать результаты с двумя столбцами таблицы film_actor. Для этого условие фильтра должно указывать два столбца из таблицы film_actor в круглых скобках и том же порядке, что и в подзапросе:
SQL Style Guide
SELECT
fa.actor_id,
fa.film_id
FROM film_actor fa
WHERE
(fa.actor_id, fa.film_id) IN (
SELECT
a.actor_id,
f.film_id
FROM actor a
CROSS JOIN film f
WHERE a.last_name = 'MONROE'
AND f.rating = 'PG'
);%%sql
SELECT actor_id, film_id
FROM film_actor
WHERE (actor_id, film_id) IN (
SELECT a.actor_id, f.film_id
FROM actor a
CROSS JOIN film f
WHERE a.last_name = 'MONROE'
AND f.rating = 'PG'
);Эта версия выполняет ту же функцию, что и в предыдущем примере, но с использованием одного подзапроса, который возвращает два столбца вместо двух подзапросов, каждый из которых возвращает один столбец.
Подзапрос в этой версии использует тип соединения, именуемого перекрестным соединением (которое будет рассмотрено в следующей главе). Основная идея – вернуть все комбинации актеров с фамилией MONROE (2) и всех фильмов с рейтингом PG (194), всего – 388 строк, 11 из которых могут быть найдены в таблице film_actor.
Коррелированные подзапросы¶
Все подзапросы, показанные до сих пор, не зависели от содержащихся в них операторов. Это означает, что вы можете выполнить их автономно и проверить возвращаемые ими результаты.
Коррелированный же подзапрос зависит от содержащей его инструкции, ссылаясь на один или несколько ее столбцов.
В отличие от некоррелированного, коррелированный подзапрос не выполняется один раз перед выполнением содержащей его инструкции. Вместо этого коррелированный подзапрос выполняется по одному разу для каждой строки-кандидата (строки, которая может быть включена в окончательный результат).
Например, в следующем запросе используется коррелированный подзапрос для подсчета количества прокатов фильмов для каждого клиента, а содержащий запрос затем извлекает тех клиентов, которые взяли напрокат ровно 20 фильмов:
%%sql
SELECT c.first_name, c.last_name
FROM customer c
WHERE 20 = (
SELECT COUNT(*)
FROM rental r
WHERE r.customer_id = c.customer_id
);Ссылка на c.customer_id в самом конце подзапроса делает этот подзапрос коррелированным; содержащий запрос должен предоставлять значения для c.customer_id чтобы подзапрос мог быть выполнен.
В данном случае содержащий запрос извлекает все 599 строк из таблицы customer и выполняет подзапрос по одному разу для каждого клиента, передавая при каждом выполнении соответствующий идентификатор клиента. Если подзапрос возвращает значение 20, условие фильтра выполняется и строка добавляется к результирующему набору.
Работает как цикл: берет строку клиента → лезет в таблицу аренд → считает количество → проверяет, равно ли оно 20.
Помимо условий равенства, можно использовать коррелированные подзапросы в условиях других типов, таких, например, как условие диапазона:
SQL Style Guide
-- Оригинал автора
SELECT c.first_name, c.last_name
FROM customer c
WHERE
(SELECT SUM(p.amount) FROM payment p
WHERE p.customer_id = c.customer_id)
BETWEEN 180 AND 240;-- Канонический
SELECT
c.first_name,
c.last_name
FROM customer c
WHERE
(
SELECT SUM(p.amount)
FROM payment p
WHERE p.customer_id = c.customer_id
) BETWEEN 180 AND 240;-- Компактный (без лишнего отступа для открывающей скобки)
SELECT
c.first_name,
c.last_name
FROM customer c
WHERE (
SELECT SUM(p.amount)
FROM payment p
WHERE p.customer_id = c.customer_id
) BETWEEN 180 AND 240;%%sql
SELECT
c.first_name,
c.last_name
FROM customer c
WHERE (
SELECT SUM(p.amount)
FROM payment p
WHERE p.customer_id = c.customer_id
) BETWEEN 180 AND 240;Этот вариант предыдущего запроса находит всех клиентов, чьи общие платежи за все прокаты фильмов составляют от 180 до 240 долларов. И вновь коррелированный подзапрос здесь используется 599 раз (по одному разу для каждой строки клиента); каждое выполнение подзапроса возвращает общий баланс для данного клиента.
Та же циклическая механика (коррелированный подзапрос), только вместо простого подсчёта строк происходит агрегация денег через SUM с последующей проверкой диапазона: берёт клиента → лезет в payment за его платежами → считает общую сумму SUM(amount) → проверяет BETWEEN 180 AND 240 → если TRUE, выводит имя и фамилию.
Еще одно тонкое отличие показанного запроса заключается в том, что этот подзапрос находится в левой части условия (что может выглядеть немного странно, но совершенно корректно).
Оператор exist¶
Хотя вы часто будете встречаться с коррелированными подзапросами, используемыми в условиях равенства и диапазона, наиболее распространенный оператор, используемый для создания условий с коррелированными подзапросами – это оператор exists.
Оператор exists используется когда нужно определить существование связи безотносительно к количеству. Например, следующий запрос находит всех клиентов, которые взяли напрокат хотя бы один фильм до 25 мая 2005 года, без учета того сколько фильмов было взято:
%%sql
SELECT c.first_name, c.last_name
FROM customer c
WHERE EXISTS (
SELECT 1
FROM rental r
WHERE r.customer_id = c.customer_id
AND DATE(r.rental_date) < DATE '2005-05-25'
);Используя оператор exists подзапрос может возвращать нуль, одну или несколько строк. И условие просто проверяет вернул ли подзапрос хотя бы одну строку.
Если посмотреть на предложение select подзапроса, то видно что он состоит из единственного литерала (1), так как условию в содержащем запросе нужно только знать сколько строк было возвращено. Фактические данные, возвращаемые подзапросом, значения не имеют.
%%sql
SELECT 1
FROM rental r
WHERE DATE(r.rental_date) < DATE '2005-05-25';Вообще подзапрос может возвращать все что угодно:
%%sql
SELECT c.first_name, c.last_name
FROM customer c
WHERE EXISTS (
SELECT r.rental_date, r.customer_id, 'ABCD' str, 2*3/7 nmbr
FROM rental r
WHERE r.customer_id = c.customer_id
AND DATE(r.rental_date) < DATE '2005-05-25'
);Однако при использовании EXISTS принято указывать SELECT 1
EXISTS принято указывать SELECT 1Оператору EXISTS абсолютно всё равно, какие данные возвращает подзапрос. Его интересует только один-единственный факт:
Вернулась хотя бы одна строка или результат пустой? (Да/Нет, True/False).
Поэтому внутри EXISTS программисты пишут SELECT 1 как символ:
Мне не нужны настоящие данные из таблицы (имена, даты, ID), не трать ресурсы на их извлечение – мне просто нужно знать, что строка существует!
SELECT 1 – это общепринятая в индустрии конвенция для подзапросов с EXISTS. Она визуально сигнализирует человеку, читающему код:
Здесь проверяется только факт наличия строки, сами значения нам не важны.
Вы также можете использовать not exists для отбора подзапросов, которые не возвращают строк:
%%sql
SELECT a.first_name, a.last_name
FROM actor a
WHERE NOT EXISTS (
SELECT 1
FROM film_actor fa
INNER JOIN film f ON fa.film_id = f.film_id
WHERE fa.actor_id = a.actor_id
AND f.rating = 'R'
);Этот запрос находит всех актеров, которые никогда не снимались в фильмах с рейтингом R.
Условие WHERE fa.actor_id = a.actor_id – главный инсайт главы
WHERE fa.actor_id = a.actor_id – главный инсайт главыЭто мост (корреляция) между внешним миром и подзапросом, который превращает глобальный вопрос в персональный:
Не Есть ли вообще фильмы с рейтингом R?
А Есть ли фильмы с рейтингом R именно у этого конкретного актера (у которого fa.actor_id совпадает с a.actor_id)?
Условие WHERE fa.actor_id = a.actor_id связывает подзапрос с текущей строкой содержащего запроса, делая проверку индивидуальной для каждого человека. Без этого условия подзапрос проверял бы всю таблицу целиком, а не конкретного актера.
Работа с данными с помощью коррелированных подзапросов¶
Все приведенные до сих пор в главе примеры были инструкциями select. Но не думайте, что это означает, что подзапросы бесполезны в других инструкциях SQL.
Подзапросы также широко используются в инструкциях update, delete и insert. Причем особенно часто коррелированные подзапросы появляются в инструкциях update и delete.
Вот пример коррелированного подзапроса, используемого для изменения столбца last_update в таблице customer:
UPDATE customer c
SET c.last_update = (
SELECT MAX(r.rental_date)
FROM rental r
WHERE r.customer_id = c.customer_id
);Эта инструкция изменяет каждую строку в таблице клиентов (поскольку в ней нет предложения where), находя последнюю дату проката для каждого клиента в таблице rental.
Хотя кажется разумным ожидать, что у каждого клиента будет хотя бы один прокат фильма, все же лучше всего проверить это прежде чем пытаться обновить столбец last_update; в противном случае для столбца будет установлено значение NULL, поскольку подзапрос не вернет никакой строки.
Вот скорректированная версия инструкции update, на этот раз использующая предложение where со вторым коррелированным подзапросом:
UPDATE customer c
SET c.last_update =
(SELECT MAX(r.rental_date) FROM rental r
WHERE r.customer_id = c.customer_id)
WHERE EXISTS
(SELECT 1 FROM rental r
WHERE r.customer_id = c.customer_id);Эти два коррелированных подзапроса идентичны, за исключением предложений select. Однако подзапрос в предложении set выполняется, только если условие в предложении where инструкции update истинно (т.е. если для клиента был найден хотя бы один прокат), тем самым защищая данные в столбце last_update от перезаписывания значением NULL.
Коррелированные подзапросы также распространены в инструкциях delete. Например, вы можете запускать сценарий обслуживания данных в конце каждого месяца, который удаляет ненужные данные. Сценарий может включать следующую инструкцию, которая удаляет те строки из таблицы customer, для которых в прошлом году не было проката фильмов:
DELETE FROM customer
WHERE 365 < ALL (
SELECT DATEDIFF(NOW(), r.rental_date) AS days_since_last_rental
FROM rental r
WHERE r.customer_id = customer.customer_id
);Применение подзапросов¶
Теперь, когда мы узнали о различных типах подзапросов и различных операторах, которые можно использовать для взаимодействия с данными, возвращаемыми подзапросами, пришло время изучить множество способов использования подзапросов для создания мощных инструкций SQL.
В следующих трех разделах показано как можно использовать подзапросы для создания пользовательских таблиц, построения условия и генерации значений столбцов в результирующих наборах.
Подзапросы как источники данных¶
Еще в главе 3 было указано, что предложение from инструкции select содержит таблицы, которые будут использоваться запросом. Поскольку подзапрос генерирует результирующий набор, содержащий строки и столбцы данных, вполне допустимо включать подзапросы в предложение from вместе с таблицами.
Хотя на первый взгляд это может показаться интересной возможностью без особой практической ценности, использование подзапросов вместе с таблицами является одним из самых мощных инструментов, доступных при написании запросов. Вот простой пример:
%%sql
SELECT
c.first_name,
c.last_name,
pymnt.num_rentals,
pymnt.tot_payments
FROM customer c
INNER JOIN (
SELECT
customer_id,
COUNT(*) AS num_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id
) AS pymnt
ON c.customer_id = pymnt.customer_id;В этом примере подзапрос генерирует список идентификаторов клиентов вместе с количеством прокатов фильмов и общими платежами.
Вот как выглядит результирующий набор, сгенерированный подзапросом:
%%sql
SELECT
customer_id,
COUNT(*) AS num_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id;Подзапрос получает имя pymnt и соединяется с таблицей customer через столбец customer_id. Затем содержащий запрос извлекает имя клиента из таблицы customer вместе со сводными столбцами из подзапроса pymnt.
Подзапросы, используемые в предложении from должны быть некоррелированными[1]; они выполняются первыми и их данные хранятся в памяти до тех пор, пока не завершится выполнение содержащего запроса.
При написании запросов подзапросы предлагают огромную гибкость, потому что вы можете выйти далеко за рамки имеющегося множества доступных таблиц для создания практически любого требуемого представления данных с последующим соединением результатов с другими таблицами или подзапросами.
При написании отчетов или генерации потоков данных во внешние системы можно с помощью одного запроса решать задачи, которые иначе требовали бы вополнения нескольких запросов или применения процедурного языка программирования.
Создание данных¶
Наряду с использованием подзапросов для подытоживания существующих данных можно использовать подзапросы для генерации данных, которых в базе данных нет ни в какой форме.
Например, можно сгруппировать клиентов по сумме денег, потраченной на прокат фильмов, но при этом вы хотите использовать определения групп, которых нет в вашей базе данных. Например, допустим, что вы хотите распределить клиентов по группам:
| Группа | Нижняя граница, долл | Верхняя граница, долл |
|---|---|---|
| Small Fry | 0 | 74.99 |
| Average Joes | 75 | 149.99 |
| Heavy Hitters | 150 | 9999999.99 |
Чтобы сгенерировать эти группы в рамках одного запроса, требуется способ определить эти три группы. Первым шагом является создание запроса, который генерирует определения групп:
%%sql
SELECT 'Small Fry' name, 0 low_limit, 74.99 high_limit
UNION ALL
SELECT 'Average Joes' name, 75 low_limit, 149.99 high_limit
UNION ALL
SELECT 'Heavy Hitters' name, 150 low_limit, 9999999.99 high_limit;Здесь использован оператор union all, чтобы объединить результаты трех отдельных запросов в единый результирующий набор. Каждый запрос извлекает три литерала, а результаты трех запросов объединяются для создания результирующего набора с тремя строками и тремя столбцами.
Теперь, когда у нас есть запрос для создания требуемых групп, его можно поместить в редложение from другого запроса для генерации групп клиентов:
SELECT
pymnt_grps.name,
COUNT(*) AS num_customers
FROM (
SELECT
customer_id,
COUNT(*) AS num_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id
) AS pymnt
INNER JOIN (
SELECT 'Small Fry' AS name, 0 AS low_limit, 74.99 AS high_limit
UNION ALL
SELECT 'Average Joes', 75, 149.99 -- 1
UNION ALL
SELECT 'Heavy Hitters', 150, 9999999.99
) AS pymnt_grps
ON pymnt.tot_payments -- 2
BETWEEN pymnt_grps.low_limit
AND pymnt_grps.high_limit
GROUP BY pymnt_grps.name;
/* 1. В SQL имена колонок для всего блока UNION задаются только в первом SELECT.
Повторять алиасы AS name, AS low_limit во второй и третьей строках
не имеет смысла – СУБД их всё равно проигнорирует.
2. Соедини строку клиента с той строкой справочника,
где сумма клиента попадает между нижней и верхней границей. */Предложение from содержит два подзапроса; первый подзапрос с именем pymnt (как увидели в предыдущем разделе) возвращает общее количество прокатов фильмов и общие платежи для каждого клиента, в то время как второй подзапрос с именем pymnt_grps генерирует три группы клиентов.
Два подзапроса объединяются путем определения, к какой из трех групп принадлежит каждый покупатель, а затем строки группируются по имени группы для подсчета количества клиентов в каждой группе.
Конечно, вы можете просто создать постоянную (или временную) таблицу для хранения определений групп вместо использования подзапроса. Используя такой подход, вы обнаружите, что через некоторое время ваша база данных будет завалена небольшими таблицами специального назначения и вы не сможете вспомнить причину по которой было создано большинство из них.
Однако, используя подзапросы, вы сможете придерживаться политики, согласно которой таблицы добавляются в базу данных только тогда, когда существует явная бизнес-потребность в хранении новых данных.
Подзапросы, ориентированные на задачу¶
Допустим, вы хотите создать отчет в котором будут указаны имя каждого клиента, а также его город, общее количество прокатов и общая сумма платежа. Это можно сделать соединив таблицы payment, customer, address и city, а затем сгруппировав их по имени и фамилии клиента:
%%sql
SELECT
c.first_name,
c.last_name,
ct.city,
SUM(p.amount) AS tot_payments,
COUNT(*) AS tot_rentals
FROM payment p
INNER JOIN customer c ON p.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
GROUP BY
c.first_name,
c.last_name,
ct.city;Этот запрос возвращает желаемые данные, но если внимательно на него посмотрите, то увидите, что таблицы customer, address и city нужны только для отображения и что в таблице payment есть все необходимое для создания группировок (customer_id и amount).
Таким образом, вы можете выделить задачу создания групп в подзапрос, а затем (для достижения желаемого конечного результата) присоединить остальные три таблицы к таблице, сгенерированной подзапросом.
Вот какой вид имеет подзапрос группировки:
%%sql
SELECT
customer_id,
COUNT(*) AS tot_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id;Это центральная часть запроса; прочие таблицы нужны только для предоставления значимых строк вместо значения customer_id.
Следующий запрос[2] соединяет предыдущий набор данных с тремя другими таблицами:
SELECT
c.first_name,
c.last_name,
ct.city,
pymnt.tot_payments,
pymnt.tot_rentals
FROM (
SELECT
customer_id,
COUNT(*) AS tot_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id
) AS pymnt
INNER JOIN customer c ON pymnt.customer_id = c.customer_id
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
ORDER BY c.customer_id;Я понимаю, что красота – в глазах смотрящего, но считаю, что эта версия запроса гораздо более привлекательна, чем большая плоская версия.
Этот запрос, кроме того, может выполняться быстрее, поскольку группировка выполняется по одному числовому столбцу customer_id, а не по нескольким столбцам с длинными строками customer.first_name, customer.last_name, city.city).
Обобщенные табличные выражения¶
Обобщенные табличные выражения (Common table expressions, CTE), которые появились в MySQL в версии 8.0, уже были доступны на других серверах баз данных в течение некоторого времени.
CTE – это именованный подзапрос, который появляется в верхней части запроса в предложении with, которое может содержать несколько CTE через запятую. Помимо того что запросы при этом становятся более понятными, это также позволяет каждому CTE обращаться к любому другому CTE, определенному над ним в том же предложении with.
Следующий пример[3] включает три обобщенных табличных выражения, причем второе ссылается на первое, а третье – на второе:
WITH actors_s AS (
SELECT actor_id, first_name, last_name
FROM actor
WHERE last_name LIKE 'S%'
),
actors_s_pg AS (
SELECT
s.actor_id,
s.first_name,
s.last_name,
f.film_id,
f.title
FROM actors_s AS s
INNER JOIN film_actor fa ON s.actor_id = fa.actor_id
INNER JOIN film f ON fa.film_id = f.film_id
WHERE f.rating = 'PG'
),
actors_s_pg_revenue AS (
SELECT
spg.first_name,
spg.last_name,
p.amount
FROM actors_s_pg AS spg
INNER JOIN inventory i ON spg.film_id = i.film_id
INNER JOIN rental r ON i.inventory_id = r.inventory_id
INNER JOIN payment p ON r.rental_id = p.rental_id
)
SELECT
spg_rev.first_name,
spg_rev.last_name,
SUM(spg_rev.amount) AS tot_revenue
FROM actors_s_pg_revenue AS spg_rev
GROUP BY
spg_rev.first_name,
spg_rev.last_name
ORDER BY tot_revenue DESC;Этот запрос вычисляет общий доход от проката тех фильмов с рейтингом PG, актерский состав которых включает актера, фамилия которого начинается на S.
Первый подзапрос
actors_sнаходит всех актеров, чьи фамилии начинаются с S;Второй подзапрос
actors_s_pgсоединяет этот набор данных с таблицей film и фильтрует их фильмы с рейтингом PG;Третий подзапрос
actors_s_pg_revenueсоединяет этот набор данных с таблицей payment, чтобы узнать суммы, уплаченные за арену любого из этих фильмов;Последний запрос просто группирует данные из actors_s_pg_reverue по имени/фамилии и суммирует доходы.
Все, кто склонны использовать временные таблицы для хранения результатов запросов для их использования в последующих запросах, могут счесть CTE привлекательной альтернативой.
WITH pymnt AS (
SELECT
customer_id,
COUNT(*) AS num_rentals,
SUM(amount) AS tot_payments
FROM payment
GROUP BY customer_id
),
pymnt_grps AS (
SELECT 'Small Fry' AS name, 0 AS low_limit, 74.99 AS high_limit
UNION ALL
SELECT 'Average Joes', 75, 149.99
UNION ALL
SELECT 'Heavy Hitters', 150, 9999999.99
)
SELECT
pg.name,
COUNT(*) AS num_customers
FROM pymnt p
INNER JOIN pymnt_grps AS pg
ON p.tot_payments BETWEEN pg.low_limit AND pg.high_limit
GROUP BY pg.name;Подзапросы как генераторы выражений¶
В этом разделе заканчиваем то, с чего начали: одностолбцовые, однострочные скалярные подзапросы.
Скалярные подзапросы могут использоваться не только в условиях фильтрации, но и везде, где может появиться выражение, включая предложения select и order by запроса и предложение value инструкции insert.
В разделе Подзапросы, ориентированные на задачу было показано как использовать подзапросы для отделения механизма группировки от остальной части запроса. Вот еще одна версия того же запроса, который использует подзапросы для той же цели, но иным образом:
SQL Style Guide
SELECT
(
SELECT c.first_name
FROM customer c
WHERE c.customer_id = p.customer_id
) AS first_name,
(
SELECT c.last_name
FROM customer c
WHERE c.customer_id = p.customer_id
) AS last_name,
(
SELECT ct.city
FROM customer c
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
WHERE c.customer_id = p.customer_id
) AS city,
SUM(p.amount) AS tot_payments,
COUNT(*) AS tot_rentals
FROM payment p
GROUP BY p.customer_id;SELECT
(SELECT c.first_name
FROM customer c
WHERE c.customer_id = p.customer_id
) AS first_name,
(SELECT c.last_name
FROM customer c
WHERE c.customer_id = p.customer_id
) AS last_name,
(SELECT ct.city
FROM customer c
INNER JOIN address a ON c.address_id = a.address_id
INNER JOIN city ct ON a.city_id = ct.city_id
WHERE c.customer_id = p.customer_id
) AS city,
SUM(p.amount) AS tot_payments,
COUNT(*) AS tot_rentals
FROM payment p
GROUP BY p.customer_id;Между этим запросом и более ранней его версией, использующей подзапрос в предложении from имеется два основных различия.
Вместо того чтобы соединять таблицы customer, address и city с данными о платежах, коррелированные скалярные подзапросы используются в предложении
selectдля поиска имени/фамилии и города клиента.К таблице customer имеется три обращения (по одному в каждом из трех подзапросов), а не одно.
К таблице customer выполняется три обращения, потому что скалярные подзапросы могут возвращать только один столбец и строку. Поэтому если нам нужны три столбца, связанные с клиентом, необходимо использовать три разных подзапроса.
Как отмечалось ранее, скалярные подзапросы могут появляться и в предложении order by. Следующий запрос извлекает имена и фамилии актеров и сортирует их по количеству фильмов, в которых снялся актер:
%%sql
SELECT a.actor_id, a.first_name, a.last_name
FROM actor a
ORDER BY (
SELECT COUNT(*)
FROM film_actor fa
WHERE fa.actor_id = a.actor_id
) DESC;Этот запрос использует коррелированный скалярный подзапрос в предложении order by только для возврата количества фильмов, и это значение используется для сортировки.
Наряду с коррелированными скалярными подзапросами в инструкциях select можно использовать некоррелированные скалярные подзапросы для генерации значений для инструкции insert.
Пусть, например, вы собираетесь создать новую строку в таблице film_actor и у вас имеются следующие данные:
имя и фамилия актера;
название фильма.
Есть два варианта как поступить:
выполнить два запроса, чтобы получить значения первичных ключей из таблиц film и actor и поместить эти значения в инструкцию
insert;или использовать для получения двух значений ключей в инструкции
insertподзапросы. Вот пример:
INSERT INTO film_actor (actor_id, film_id, last_update)
VALUES (
(SELECT actor_id
FROM actor
WHERE first_name = 'JENNIFER' AND last_name = 'DAVIS'),
(SELECT film_id
FROM film
WHERE title = 'ACE GOLDFINGER'),
NOW()
);Профессиональный трюк без VALUES
VALUESЛюбимый приём разработчиков в реальных проектах:
В SQL конструкцию INSERT INTO ... VALUES ((SELECT ...), (SELECT ...)) можно переписать вообще без предложения VALUES и без кучи круглых скобок, используя INSERT INTO ... SELECT:
INSERT INTO film_actor (actor_id, film_id, last_update)
SELECT
a.actor_id,
f.film_id,
NOW()
FROM actor a
CROSS JOIN film f
WHERE a.first_name = 'JENNIFER'
AND a.last_name = 'DAVIS'
AND f.title = 'ACE GOLDFINGER';Никаких скалярных подзапросов, никаких десятков скобок. Обычный чистый SELECT через CROSS JOIN, результат которого напрямую заливается в таблицу film_actor.
В заключение¶
В этой главе затрагивалось много вопросов, поэтому было бы неплохо бегло повторить основные тезисы. Примеры в этой главе демонстрируют подзапросы, которые:
возвращают один столбец и одну строку, один столбец с несколькими строками и несколько столбцов и строк;
не зависят от содержащей инструкции (некоррелированные подзапросы);
ссылаются на один или несколько столбцов из содержащей инструкции (коррелированные подзапросы);
используются в условиях, в которых используются операторы сравнения, а также специальные операторы
in,not in,existsиnot exists;могут применяться в инструкциях
select,update,deleteиinsert;могут создавать результирующие наборы, которые можно соединять с другими таблицами (или подзапросами) в запросе;
могут использоваться для генерации значений для заполнения таблицы или столбцов в результирующем набора запроса;
используются в предложениях
select,from,where,havingиorder byзапросов.
Очевидно, что подзапросы являются очень мощным универсальным инструментом, поэтому не расстраивайтесь, если вы не смогли усвоить все эти концепции с первого прочтения главы.
Продолжайте экспериментировать в различными вариантами использования подзапросов и вскоре вы поймаете себя на том, что каждый раз когда пишите нетривиальную инструкцию SQL, вы думаете о том, как бы использовать в ней подзапрос.
Упражнения¶
Упражнение 9.1¶
Создайте запрос к таблице film, который использует условие фильтрации с некоррелированным подзапросом к таблице category, чтобы найти все боевики (category.name = ‘Action’).
%%sql
SELECT film_id, title
FROM film
WHERE film_id IN (
SELECT fc.film_id
FROM film_category fc
INNER JOIN category c ON fc.category_id = c.category_id
WHERE c.name = 'Action'
);Упражнение 9.2¶
Переработайте запрос из упражнения 9.1, используя коррелированный подзапрос к таблицам category и film_category для получения тех же результатов.
%%sql
SELECT f.film_id, f.title
FROM film f
WHERE EXISTS (
SELECT 1
FROM film_category fc
INNER JOIN category c ON fc.category_id = c.category_id
WHERE c.name = 'Action'
AND fc.film_id = f.film_id
);Упражнение 9.3¶
Соедините следующий запрос с подзапросом к таблице film_actor, чтобы показать уровень мастерства каждого актера:
SELECT 'Hollywood Star' level, 30 min_roles, 99999 max_roles
UNION ALL
SELECT 'Prolific Actor' level, 20 min_roles, 29 max_roles
UNION ALL
SELECT 'Newcomer' level, 1 min_roles, 19 max_rolesПодзапрос к таблице film_actor должен подсчитывать количество строк для каждого актера с использованием group by actor_id и результат подсчета должен сравниваться со столбцами min_roles/max_roles, чтобы определить, какой уровень мастерства имеет каждый актер.
%%sql
SELECT
actr.actor_id,
grps.level
FROM (
SELECT
actor_id,
COUNT(*) AS num_roles
FROM film_actor
GROUP BY actor_id
) AS actr
INNER JOIN (
SELECT 'Hollywood Star' AS level, 30 AS min_roles, 99999 AS max_roles
UNION ALL
SELECT 'Prolific Actor', 20, 29
UNION ALL
SELECT 'Newcomer', 1, 19
) AS grps
ON actr.num_roles
BETWEEN grps.min_roles
AND grps.max_roles;%%sql
/* Вариант 1 (предпочтительный)
Добавить обычный `INNER JOIN actor a` в основной внешний запрос */
SELECT
a.actor_id,
a.first_name,
a.last_name,
grps.level
FROM (
SELECT
actor_id,
COUNT(*) AS num_roles
FROM film_actor
GROUP BY actor_id
) AS actr
INNER JOIN actor a
ON actr.actor_id = a.actor_id
INNER JOIN (
SELECT 'Hollywood Star' AS level, 30 AS min_roles, 99999 AS max_roles
UNION ALL
SELECT 'Prolific Actor', 20, 29
UNION ALL
SELECT 'Newcomer', 1, 19
) AS grps
ON actr.num_roles BETWEEN grps.min_roles AND grps.max_roles;%%sql
/* Вариант 2
Сджойнить `actor` сразу внутри первого подзапроса */
SELECT
actr.actor_id,
actr.first_name,
actr.last_name,
grps.level
FROM (
SELECT
a.actor_id,
a.first_name,
a.last_name,
COUNT(*) AS num_roles
FROM film_actor fa
INNER JOIN actor a ON fa.actor_id = a.actor_id
GROUP BY
a.actor_id,
a.first_name,
a.last_name
) AS actr
INNER JOIN (
SELECT 'Hollywood Star' AS level, 30 AS min_roles, 99999 AS max_roles
UNION ALL
SELECT 'Prolific Actor', 20, 29
UNION ALL
SELECT 'Newcomer', 1, 19
) AS grps
ON actr.num_roles BETWEEN grps.min_roles AND grps.max_roles;Фактически в зависимости от используемого сервера базы данных можно включать в предложение
fromкоррелированные подзапросы с помощью конструкцийcross applyилиexternal apply, но эти возможности выходят за рамки данной книги.Выборочно использую для подсветки синтаксиса на сайте прием из Главы 2.
Выборочно использую для подсветки синтаксиса на сайте прием из Главы 2.