Изучение тонкостей взаимодействия между первичными ключами SQL и операторами `Group By`
Сегодня знакомый задал мне интересный вопрос по SQL, но не в типичном для запроса смысле; его вопрос больше касался понимания базовой структуры SQL. Вот контекст:
1. Проблема
Это упражнение взято с этой страницы упражнений PostgreSQL и имеет следующую схему:
Вопрос SQL, который задает веб-сайт, не важен, но опубликованное решение таково:
SELECT facs.name AS name, facs.initialoutlay/((sum( CASE WHEN memid = 0 THEN slots * facs.guestcost ELSE slots * membercost END)/3) - facs.monthlymaintenance) AS months FROM cd.bookings bks INNER JOIN cd.facilities facs ON bks.facid = facs.facid GROUP BY facs.facid ORDER BY name;
Мой друг попытался переключить строку GROUP BY facs.facid
на GROUP BY facs.name
, что нарушило запрос с сообщением:
column "facs.initialoutlay" must appear in the GROUP BY clause or be used in an aggregate function
Мой друг задал вопрос:
Почему приведенный выше запрос не работает с переключенными строками, даже если оба столбца уникальны для каждой строки?
Если вы знаете ответ, поздравляю, вы должны ответить на мой вопрос, размещенный в конце! Если вы хотите перейти к ответу, прокрутите до раздела «Ответ» этой статьи. В противном случае давайте задумаемся над решением этого вопроса!
2. Подход
Во-первых, давайте проверим очевидное: действительно ли эти столбцы уникальны и используются ли в нашем запросе несколько комбинаций столбцов (name, facid, initialoutlay, monthlymaintenance
)? Чтобы проверить это, мы смотрим на различные комбинации этих столбцов в таблице facilities
.
SELECT DISTINCT facs.name as name, facs.facid, facs.initialoutlay, facs.monthlymaintenance FROM cd.bookings bks INNER JOIN cd.facilities facs ON bks.facid = facs.facid ORDER BY facid; Output: ╔═════════════════╦═══════╦═══════════════╦════════════════════╗ ║ name ║ facid ║ initialoutlay ║ monthlymaintenance ║ ╠═════════════════╬═══════╬═══════════════╬════════════════════╣ ║ Tennis Court 1 ║ 0 ║ 10000 ║ 200 ║ ║ Tennis Court 2 ║ 1 ║ 8000 ║ 200 ║ ║ Badminton Court ║ 2 ║ 4000 ║ 50 ║ ║ Table Tennis ║ 3 ║ 320 ║ 10 ║ ║ Massage Room 1 ║ 4 ║ 4000 ║ 3000 ║ ║ Massage Room 2 ║ 5 ║ 4000 ║ 3000 ║ ║ Squash Court ║ 6 ║ 5000 ║ 80 ║ ║ Snooker Table ║ 7 ║ 450 ║ 15 ║ ║ Pool Table ║ 8 ║ 400 ║ 15 ║ ╚═════════════════╩═══════╩═══════════════╩════════════════════╝
Name
и facid
уникальны для каждой строки и от 1 до 1, и каждая пара имеет только одно значение initialoutlay
и monthlymaintenance
. Интуитивно понятно, что группировка по одному из этих двух столбцов должна быть функционально эквивалентна группировке по другому. Так почему же для этого запроса не работает группировка по name
?
3. Первичный ключ
Как вы можете догадаться, если вы знакомы с SQL, это проблема первичного ключа. Для тех, кто не знает, первичный ключ - это значение, однозначно идентифицирующее каждую строку таблицы, и оно никогда не может быть NULL для строки. Но как найти назначенный первичный ключ таблицы?
Быстрый поиск в Google дает нам следующий код из PostgreSQL wiki. Выполнение этого в разделе запроса веб-сайта дает следующее:
SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS data_type FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = 'cd.facilities'::regclass AND i.indisprimary; Output: ╔═════════╦═══════════╗ ║ attname ║ data_type ║ ╠═════════╬═══════════╣ ║ facid ║ integer ║ ╚═════════╩═══════════╝
Итак, facid
- это первичный ключ таблицы facilities
! Теперь мы подтвердили вероятную причину, но по какой причине группировка по первичному ключу позволяет добавлять столбцы без агрегатной функции, как мы это делаем с facs.initialoutlay
и facs.monthlymaintenance
?
SELECT facs.name AS name, facs.initialoutlay/((sum( /* <=========== */ CASE WHEN memid = 0 THEN slots * facs.guestcost ELSE slots * membercost END)/3) - facs.monthlymaintenance) AS months /* <=========== */ FROM cd.bookings bks INNER JOIN cd.facilities facs ON bks.facid = facs.facid GROUP BY facs.facid ORDER BY name; /* Shouldn't these two columns be inside of an aggregation? */
Чтобы ответить на этот вопрос, мы смотрим Справочную документацию PostgreSQL, специально для GROUP BY
:
Когда присутствует
GROUP BY
или присутствуют какие-либо агрегатные функции, недопустимо, чтобы выражения спискаSELECT
ссылались на несгруппированные столбцы, за исключением агрегатных функций, или когда несгруппированный столбец функционально зависит от сгруппированных столбцов, поскольку в противном случае было бы больше, чем одно возможное значение, возвращаемое для несгруппированного столбца. Функциональная зависимость существует, если сгруппированные столбцы (или их подмножество) являются первичным ключом таблицы, содержащей несгруппированный столбец.
Как пользователь Stack Overflow Тони Л. кладет это:
Группировка по первичному ключу приводит к одной записи в каждой группе, что логически аналогично отсутствию группировки / группировки по всем столбцам, поэтому мы можем выбрать все остальные столбцы.
По сути, это означает, что группировка по первичному ключу таблицы не приводит к изменению строк в этой таблице, поэтому если мы группируем по первичному ключу таблицы, мы можем вызывать все столбцы этой таблицы без агрегатной функции .
Повторим: Учитывая, что мы смотрим на одну таблицу, группировка по ее первичному ключу аналогична группировке по всему, что равносильно отсутствию группировки вообще - каждый из этих подходов приведет к по одной группе в ряду. Как только вы это поймете, вы поймете суть проблемы.
Из-за этого работают такие запросы:
1. Group by everything: SELECT * FROM cd.facilities f GROUP BY facid, name, membercost, guestcost, initialoutlay, monthlymaintenance LIMIT 5 OUTPUT:
который функционально идентичен
2. Don't group by anything SELECT * FROM cd.facilities f LIMIT 5 and 3. Group by primary key but don't aggregate SELECT * FROM cd.facilities f GROUP BY facid LIMIT 5
Все они выводят одинаковые значения! Теперь у нас есть решение.
3. Ответ
Причина, по которой первый запрос работает, заключается просто в том, что facid
является первичным ключом, а name
- нет. Несмотря на то, что они обе уникальны для каждой строки, таблица facilities
была создана с facid
в качестве первичного ключа, поэтому она получает особую обработку при использовании в группе, как описано выше.
Вот некоторые альтернативные решения тому, что они опубликовали:
1. Group by name then aggregate everything else SELECT facs.name as name, AVG(facs.initialoutlay)/((sum(case when memid = 0 then slots * facs.guestcost else slots * membercost end)/3) - AVG(facs.monthlymaintenance) as months FROM cd.bookings bks INNER JOIN cd.facilities facs ON bks.facid = facs.facid GROUP BY facs.name ORDER BY name; Why this works: Because facs.name is unique to each row in the facilities table just as facid was, we group by facs.name then add AVG calls around previously unaggregated facilities columns. 2. Group by all facility columns used in the select statement SELECT facs.name as name, facs.initialoutlay/((sum(case when memid = 0 then slots * facs.guestcost else slots * membercost end)/3) - facs.monthlymaintenance) as months from cd.bookings bks INNER JOIN cd.facilities facs ON bks.facid = facs.facid GROUP BY facs.name, facs.initialoutlay, facs.guestcost, facs.monthlymaintenance ORDER BY name; Why this works: This includes all the values used in theSELECT
statement in theGROUP BY
, which is the normalGROUP BY
logic and syntax.
4. Дополнительный вопрос
На этом основной вопрос исчерпан, но если вы заинтересованы в изучении дополнительных причуд, я столкнулся со следующей проблемой при решении опубликованной проблемы. Если кто-то из вас знает, почему, не стесняйтесь пинговать меня или оставлять комментарии!
/* This works (manually lists all columns in the group by)*/ SELECT * FROM cd.facilities f GROUP BY facid, name, membercost, guestcost, initialoutlay, monthlymaintenance LIMIT 5 /* This does not (selecting all columns using f.*) */ SELECT * FROM cd.facilities f GROUP BY f.* LIMIT 5
Спасибо за чтение, надеюсь, эта статья вам помогла! Не стесняйтесь проверять мои другие уроки и оставаться в курсе новых в будущем!