Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Продолжаю публикацию расширенных транскриптов лекционного курса "PostgreSQL для начинающих", подготовленного мной в рамках "Школы backend-разработчика" в "Тензоре".
Сегодня поговорим о самых простых, но важных, возможностях команды SELECT
, наиболее часто используемой при работе с базами данных - формировании выборок (VALUES
), их ограничении (LIMIT/OFFSET/FETCH
), фильтрации (WHERE/HAVING
), сортировке (ORDER BY
), уникализации (DISTINCT
) и группировке (GROUP BY
).
Как обычно, для предпочитающих смотреть и слушать, а не читать - доступна видеозапись:
Краткий путеводитель:
Основы SQL
Простые SELECT
Сложные SELECT
Анализ запросов
Индексы
Транзакции
Блокировки
На прошлой лекции мы остановились на том, что прочитали из таблицы все данные. И выяснили, что на SQL сделать это очень просто - не надо писать ни циклов, ни итераторов, достаточно всего лишь:
SELECT * FROM <имя_таблицы>;
Впрочем, можно даже не писать SELECT * FROM
, потому что есть команда TABLE, которая делает то же самое - безусловно вычитывает из таблицы строки со всеми полями в них:
TABLE <имя_таблицы>;
На детальных разборах синтаксиса я останавливаться не буду, поскольку по приводимым ссылкам на документацию любой желающий может пройти и по-русски все подробно прочитать.
Но если вдруг кому-то начало казаться, что SELECT
- это просто, то это совсем не так, это достаточно сложно, ведь SELECT - самая богатая по количеству функционала команда, которая только есть в SQL:
Но если вы почему-то думаете, что это все - какие-то модные нововведения в связи с развитием поддержки расширений стандарта SQL, то это тоже не так.
Всех нововведений на этом слайде, относительно наиболее старой поддерживаемой сейчас версии PostgreSQL 11 всего два: опция WITH TIES
, добавленная в версии PostgreSQL 13 возможность неразрывного захвата группы "одинаковых" записей (что удобно для "постраничной навигации", например), и расширение GROUP BY DISTINCT
, представленное в PostgreSQL 14.
Если ориентироваться на типичный цикл мажорной версии PostgreSQL раз в год, а в октябре этого года вышла версия 16, то синтаксис, фактически, не менялся за последние 5 лет.
VALUES
Но давайте наше рассмотрение функционала команды SELECT
вообще не с нее, с совсем другой команды - VALUES.
Если погрузиться глубоко в исходники PostgreSQL, то VALUES
, как и TABLE
, это такой маленький SELECT
, только с куцым функционалом:
Из всего многообразия возможностей SELECT
, для VALUES
оставлено всего лишь 4, как это в русской документации называется, "предложения" (в оригинале - "clause"). Что они позволяют делать, мы и рассмотрим, для начала.
Вообще, команда VALUES
позволяет вручную определить некоторую выборку, ее формат, и из чего она будет состоять: через запятую внутри скобок - значения полей записи, вне скобок - перечисление самих записей:
Заодно, она самостоятельно задает имена столбцов (column1
, column2
, ... и далее по порядку) и их типы - если мы не указали специально тип значения в поле, то будет выбран наиболее подходящий из трех стандартных вариантов: integer
, numeric
или text
.
Как можно видеть на слайде, любые лишние пробелы или переводы строк (кроме участвующих в текстовых литералах, конечно) SQL не волнуют вообще никак, поэтому код запросов можно писать с таким форматированием, которое удобно вам или вашей команде.
Однако, при описании выборок таким способом, надо быть особо внимательным. Потому что если вдруг вы ошибетесь в типах значений, то иногда они приведутся (и не факт, что вы этого хотели), а иногда вы получите-таки ошибку:
VALUES (1), ('1');
-- 1
-- 1
VALUES (1), ('a');
-- ERROR: invalid input syntax for type integer: "a"
Еще одна проблема может поджидать любителей генерировать текст запроса в коде, если ваша стандартная функция свертки массива значений полей нехорошо обходится с NULL
или undefined
- вы рискуете получить или пустоту вместо значения, или меньше необходимого количества столбцов, что одинаково приведет к ошибке выполнения на стороне сервера СУБД:
VALUES (1,2),(3,);
-- ERROR: syntax error at or near ")"
VALUES (1,2),(3);
-- ERROR: VALUES lists must all be the same length
Помимо статичных значений, в VALUES
могут также использоваться NULL
-значения, числовые значения в экспоненциальной форме и результаты вычисления выражений:
VALUES
(1, 2.5e+0, 'al' || 'pha')
, (2 + 2, NULL, 'beta');
column1 | column2 | column3
integer | numeric | text
1 | 2.5 | alpha
4 | | beta
Здесь мы видим два выражения: 2 + 2
и 'al' || 'pha'
. Причем второе, с оператором ||
, который во многих привычных языках программирования означает "логическое ИЛИ", в PostgreSQL - конкатенация строк.
Операторы в PostgreSQL
Если вас это почему-то смущает, то стоит погрузиться в документацию, где операторам, как они могут выглядеть и из чего состоять, посвящен достаточно большой абзац:
Имя оператора образует последовательность не более чем
NAMEDATALEN
-1 (по умолчанию 63) символов из следующего списка:
+ - * / < > = ~ ! @ # % ^ & | ` ?
Однако для имён операторов есть ещё несколько ограничений:
Сочетания символов
--
и/*
не могут присутствовать в имени оператора, так как они будут обозначать начало комментария.Многосимвольное имя оператора не может заканчиваться знаком
+
или-
, если только оно не содержит также один из этих символов:~ ! @ # % ^ & | ` ?
Например,
@-
— допустимое имя оператора, а*-
— нет. Благодаря этому ограничению, PostgreSQL может разбирать корректные SQL-запросы без пробелов между компонентами.
Даже "из коробки" операторов в PostgreSQL достаточно много, но если вам их почему-то не хватило, вы можете создать свой, лишь бы он удовлетворял описанным выше правилам.
При этом даже среди "стандартных" операторов есть достаточно "заковыристые" варианты. Например, из этих четырех вы будете активно использовать, а с тремя другими, надеюсь, не столкнетесь никогда:
VALUES (|/ 36, ||/ 125, @ -1, 'al' || 'pha');
column1 | column2 | column3 | column4
double precision | double precision | integer | text
6 | 5 | 1 | alpha
Потому что операторы квадратного и кубического корней и абсолютного значения короче, да и понятнее заменить на соответствующие им функции, чего как раз не скажешь о конкатенации, поскольку писать тогда приходится больше:
VALUES (sqrt(36), cbrt(125), abs(-1), concat('al', 'pha'));
Раз мы заговорили об операторах, стоит упомянуть, что они имеют различный приоритет:
Причем все из них, кроме унарных +/-
и NOT
, вычисляются слева-направо. Оператор OR
имеет минимальный приоритет, поэтому всегда будет выполняться в последнюю очередь.
Порядок вывода строк (ORDER BY)
Но давайте все-таки вернемся к возможностям команды VALUES
и рассмотрим первое "предложение" - ORDER BY, которое позволяет задать порядок вывода получаемых нами записей.
Оно состоит из списка выражений сортировки, которые применяются последовательно - то есть сначала все записи сортируются по первому выражению, при равенстве значений в первом - по второму, и так далее.
Каждое из таких выражений может быть представлено:
позиционным номером или именем столбца сортируемой выборки
некоторым выражением от одного или нескольких полей строки (столбцов выборки) с операторами и функциями
Единственное требование - лишь бы тип столбца или результата выражения поддерживал операторы линейного порядка <, <=, =, >=, >
. То есть, например, boolean
, числа или строки отсортировать "по порядку" можно, а двухкоординатные геометрические точки - нельзя.
Дополнительно у нас могут быть указаны ключевые слова:
ASC/DESC
, которые позволяют определить направление сортировки - по возрастанию (по умолчанию) или убыванию соответственноNULLS FIRST/NULLS LAST
, определяющие положениеNULL
-значений раньше/позже всех остальных
Если же и после сортировки по последнему из выражений в нем оказались равные значения или если не было ORDER BY
в запросе вовсе, то порядок записей не определен.
То есть, в соответствии со стандартом SQL, база вправе отдавать вам записи в любом произвольном, удобном ей с точки зрения производительности, порядке, не нарушающем указанный в запросе.
Не указали ORDER BY
и надеетесь, что записи сохранят хоть какой-то порядок при следующем выполнении того же запроса?.. В большинстве случаев - зря! Для базы нет никакой разницы между "эта запись у меня записана в начале таблицы, а эта - в конце, значит, она будет выведена позже!" Да и вообще, ничто не заставляет PostgreSQL читать файл таблицы "с начала" - чтение запросто может стартовать и с его "середины".
Выполним нашу команду VALUES
без всякого указания ORDER BY
- и, несмотря на все сказанное ранее, получаем строки ровно в том порядке, в котором их указали в запросе! Такое поведение не регламентируется стандартом, но реализовано в PostgreSQL "by design".
Однако, при наличии любой сортировки порядок данных в неперечисленных там столбцах будет произвольным. Например, при сортировке по первому столбцу "по возрастанию" можно внезапно обнаружить, что второй столбец оказался отсортированным "по убыванию".
Также необходимо помнить, что если у нас возникает задача "развернуть" выборку в обратном порядке, то это надо проделать со всем перечнем сортирующих выражений:
-- прямой порядок
ORDER BY 1 ASC, 2 ASC
-- обратный порядок
ORDER BY 1 DESC, 2 DESC
При этом, если мы используем однонаправленную сортировку сразу по нескольким столбцам, их можно "объединить в кортеж" и "развернуть" сразу вместе:
-- прямой порядок
ORDER BY (column1, column2) -- ASC по умолчанию
-- обратный порядок
ORDER BY (column1, column2) DESC
Кстати насчет порядка... Учитывая два направления сортировки и два варианта позиции NULL
-значений, мы получаем 4 варианта сортировок для любого выражения:
ORDER BY x -- ASC NULLS LAST
ORDER BY x NULLS FIRST -- ASC NULLS FIRST
ORDER BY x DESC NULLS LAST -- DESC NULLS LAST
ORDER BY x DESC -- DESC NULLS FIRST
... и все они разные:
Заметьте, что обратной к "умолчательной" ASC NULLS LAST
является DESC NULLS FIRST
, что бывает удивительным для начинающих разработчиков, обнаруживающих NULL
'ы в самом начале "развернутой" выборки.
Сортировка строковых значений
Но что мы все про числа?.. Давайте посмотрим на сортировки строк.
Если мы используем "обычную" сортировку, то порядок строк будет алфавитным, причем строчные и прописные буквы окажутся рядом. Но если воспользуемся "побайтовой" сортировкой USING ~<~
, то получим весьма неожиданный результат:
Думаю, многие догадываются, что зачатки такого положения вещей были посеяны еще при портировании IBM XT и MS DOS на их советские аналоги типа ЕС-1840.
Однако, русским языком проблемы не ограничиваются и точно так же страдают поляки, немцы и многие другие народы, пишущие "с умляутами", не говоря уж про пользователей RTL-языков. Поэтому был разработан такой инструмент, как правила сортировки, которые, фактически, определяют позицию каждого символа в кодовой таблице.
Вы можете установить соответствующую кодовую таблицу на сервер PostgreSQL и использовать для сортировки. Например, установленная по умолчанию POSIX/C-кодировка может быть использована как ORDER BY column1 COLLATE "C"
и даст результат аналогичный "побайтовой" ORDER BY column1 USING ~<~
.
Ограничения результата
Второй блок "предложений" позволяет ограничить перечень строк в результирующей выборке: сколько штук хотим получить и с какой по счету начинать.
Ограничение количества строк (LIMIT)
Чтобы задать желаемое количество строк ответа (например, вы хотите выбрать из таблицы какие-то 5 строк, хотя их там миллионы), воспользуемся ключевым словом LIMIT
:
-- чтобы вернуть не более 5 строк
LIMIT 5
LIMIT '5'
-- чтобы вернуть все строки без ограничения
LIMIT NULL
LIMIT ALL
-- ... или просто не указывать LIMIT
"Не более" означает, что даже если мы установим лимит в 5 строк для выборки, где их всего 3, то и получим только 3 - ведь базе неоткуда взять еще 2.
Смещение начала (OFFSET)
Чтобы начать получать строки не с первой по порядку, воспользуемся ключевым словом OFFSET
:
-- начинаем с 6-й строки (пропускаем 5)
OFFSET 5
OFFSET '5'
-- начинаем с самого начала
OFFSET NULL
OFFSET 0
-- ... или просто не указывать OFFSET
Некоторые почему-то считают, что OFFSET
нельзя указывать без LIMIT
, но это не так:
VALUES
('a')
, ('b')
, ('c')
OFFSET 1;
-- a
-- b
Понятно, что при LIMIT 0
или OFFSET ALL
результат окажется заведомо пустым. Впрочем, первый вариант можно использовать, если вам необходимо получить только формат столбцов результата запроса, а вот второй вызовет ошибку:
ERROR: syntax error at or near "ALL"
LINE 1: OFFSET ALL;
В целом, можно вычислить, что при исходном размере выборки S
строк, заданном лимите L
и смещении O
, мы всегда получаем min(max(S - O, 0), L)
строк результата:
И такой подход позволяет достаточно просто реализовывать постраничную навигацию, если порядок выборки у вас стабилен (например, вы задали правильный однозначный и стабильный порядок с помощью ORDER BY
):
...
ORDER BY ...
LIMIT <rows_per_page>
OFFSET <rows_per_page * page_num>
FETCH
Если изначально стандарт SQL предполагал использование только пары LIMIT
и OFFSET
, то с SQL:2008 появилась комбинация OFFSET
и ключевого слова FETCH
, которое по смыслу аналогично LIMIT
с небольшими дополнениями:
...
OFFSET 1 ROWS
FETCH FIRST 1 ROWS ONLY
-- полностью тоже самое, что
...
LIMIT 1
OFFSET 1
Тут слова ROWS
и FIRST
незначимы, и поддерживаются исключительно ради соответствия стандарту, а вот ONLY
- ой как значимо! Именно эта форма дает результат эквивалентный LIMIT + OFFSET
.
А вот вторая форма WITH TIES
, появившаяся в PostgreSQL 13, заставляет базу отдавать сразу все строки неразличимого для сортировки блока, который попадает на границу выборки.
Эта возможность позволяет существенно упростить реализацию постраничной навигации:
SELECT
Давайте, наконец, вернемся от примитивного VALUES
к полноценному SELECT
, который уже посложнее.
Формат результирующей выборки
Как минимум, он отличается тем, что мы описываем те столбцы, которые хотим в результирующей выборке видеть - список возвращаемых полей. Если VALUES
формирует имена и определяет типы столбцов самостоятельно, то для SELECT
нам необходимо хоть что-то написать самим относительно того, что пришло во FROM-части.
Это может быть некоторое:
выражение от столбцов из
FROM
,имя любого из этих столбцов
или можем указать
"*"
("дай мне все столбцы из...") для части или всегоFROM
В целом, если "на вход" из FROM
нам пришла строка, то и "на выходе" мы получим строку соответствующую ей, как-то преобразованную указанными нами выражениями.
Хотя, FROM
-части в SELECT
-запросе может и не быть:
При этом мы можем задать имя столбца результирующей выборки с помощью ключевого слова AS
, хотя и без него - тоже. А можем и не задавать - в этом случае, в отличие от VALUES
, имя ему присвоено не будет, обращаться по нему мы не сможем, но получить значение - легко.
Как правило, все библиотеки и утилиты для работы с PostgreSQL, отдают результирующую выборку двумя массивами:
один описывает порядок, имена и типы столбцов, укацук
а второй содержит строки ответа в виде объектов или массивов значений полей в соответствии с порядком описания столбцов.
Либо в качестве имени столбца будет взято имя функции (random, generate_series, ...
) или оператора (CASE, coalesce, nullif, ...
). Функция при этом может генерировать как единственное значение, так и сразу набор строк.
Исходная выборка (FROM)
Раз SELECT
умеет принимать выборку "на вход" во FROM
, а VALUES
- выдавать, давайте попробуем совместить эти две команды:
SELECT
*
FROM (
VALUES
('a', 1)
, ('b', 2)
, ('a', 3)
, ('b', 4)
);
-- ERROR: VALUES in FROM must have an alias
-- HINT: For example, FROM (VALUES ...) [AS] foo.
Однако, это нельзя сделать просто так. Впрочем, сам PostgreSQL подсказывает нам, как исправить ошибку в запросе - присвоить алиасы для каждого элемента внутри FROM
:
При этом мы можем получить ситуацию дублирования не только данных, но и имен столбцов в ответе. Пока мы не попытаемся обратиться по какому-то столбцу по неоднозначному имени, это не вызывает проблем. Причем какие-то из столбцов можно просто переименовать, как в этом примере из column1
в str
.
Впрочем, на переименованиях можно сэкономить, задав имена столбцов прямо при определении алиаса:
Фильтрация исходной выборки (WHERE)
Следующее "предложение" - WHERE, определяет, какие из строк исходной выборки будут обработаны и сформируют строки результата, а какие будут отброшены.
Если формат выборки определяет, какие столбцы мы получим, то WHERE
- какие строки. И правила тут просты - если указанное boolean
-выражение дает для строки TRUE
- она попадает в выборку, если FALSE
или NULL
- нет.
Попробуем отобрать только строки со значением 'a'
в поле str
:
Еще раз напомню, что для сравнения в SQL используется "одинарный" оператор "="
.
Вычисление условий в SQL
Но как только мы добавляем еще одно условие, начинаются сложности:
WHERE условие1 AND условие2
Если кто-то привык по императивным языкам программирования, что всегда сначала будет вычислено условие1
и только после него, в зависимости от результата, - условие2
, то в SQL их ждет много сюрпризов.
В SQL порядок вычисления условий отдан на откуп реализации конкретной СУБД. Этому в документации целый абзац посвящен, а я про это даже писал отдельную статью:
Заметьте, что это отличается от «оптимизации» вычисления логических операторов слева направо, реализованной в некоторых языках программирования.
Как следствие, в сложных выражениях не стоит использовать функции с побочными эффектами. Особенно опасно рассчитывать на порядок вычисления или побочные эффекты в предложениях
WHERE
иHAVING
, так как эти предложения тщательно оптимизируются при построении плана выполнения. Логические выражения (сочетанияAND
/OR
/NOT
) в этих предложениях могут быть видоизменены любым способом, допустимым законами Булевой алгебры.Когда порядок вычисления важен, его можно зафиксировать с помощью конструкции
CASE
(см. Раздел 9.18). Например, такой способ избежать деления на ноль в предложенииWHERE
ненадёжен:
SELECT ... WHERE x > 0 AND y/x > 1.5;
Безопасный вариант:
SELECT ... WHERE CASE WHEN x > 0 THEN y/x > 1.5 ELSE false END;
Применяемая так конструкция
CASE
защищает выражение от оптимизации, поэтому использовать её нужно только при необходимости. (В данном случае было бы лучше решить проблему, переписав условие какy > 1.5*x
.)
Исключение дублей (DISTINCT)
Еще одним способом сократить количество строк в результирующей выборке является их уникализация с помощью DISTINCT:
Мы можем либо задать набор выражений (или просто столбцов), который определяет, что будет считаться дублем, а что нет. При этом, сопоставляя по набору, мы можем задать правило сортировки строк внутри "совпадающей" группы (с обязательно в начале идущими выражениями уникализации), первая из которых и будет возвращена:
Напоминаю, что при отсутствии явно заданной сортировки, база вернет вам ту запись, которая удобна ей, а не вам.
Если же мы ON
-выражения не указали, совпадение определяется по полному набору столбцов сразу. Из каждого набора "полных клонов" строк результирующей выборки будет оставлена ровно одна, отличающаяся от других хоть чем-то:
Правда, используя DISTINCT
для исправления неаккуратной реализации соединений, о которых мы поговорим на следующей лекции, вы можете сильно просадить производительность запроса. Поэтому перед использованием DISTINCT
лучше сначала всегда задуматься, нельзя ли дубли побороть как-то иначе, точно ли не ваша неаккуратность привела к их появлению.
Группировка строк (GROUP BY)
Помимо DISTINCT
, уникализировать строки нам может помочь "предложение" GROUP BY.
По описанию он очень похож на ORDER BY
, там также можно указывать номера столбцов, их имена или выражения от них, но нужен он, чтобы всю нашу выборку "разложить на кучки". А потом по каждой "кучке" посчитать некоторую агрегатную функцию.
Например, это может быть количество строк в группе (count
), минимальное/максимальное или среднее значение какого-то показателя (min/max/avg
) или его полная сумма по группе (sum
) - причем их можно считать одновременно. Или, если стандартных функций вам не хватает, можно определить свою.
Если же нам надо вычислить результат без разбиения на группы ("Моя фамилия ИТОГО!"), вы можете указать группировку по пустому набору GROUP BY ()
или не указывать GROUP BY
вовсе.
При этом результат получающейся после группировки выборки можно сразу отсортировать:
SELECT i, sum(...) ... GROUP BY 1 ORDER BY 2 DESC
-- или, если само значение агрегата неважно
SELECT i ... GROUP BY 1 ORDER BY sum(...) DESC
Фильтрация сгруппированных строк (HAVING)
Раз уж мы умеем сортировать сгруппированные строки, то почему бы их и не пофильтровать? И тут на помощь нам приходит "предложение" HAVING:
По смыслу, условие HAVING
эквивалентно условию WHERE
, с той лишь разницей, что в условии мы уже можем использовать агрегатные функции, поскольку условие применяется к результату группировки. Кто не в курсе про HAVING
, может добиться того же результата "надстройкой" еще одного уровня вложенности запроса с WHERE
, но зачем писать больше?..
Итого, в этой лекции мы разобрали следующие возможности команды SELECT
, и это оказалось все-таки не так уж сложно!
SELECT [ DISTINCT [ ON ( выражение [, ...] ) ] ]
[ * | выражение [ [ AS ] имя_результата ] [, ...] ]
[ FROM элемент_FROM [, ...] ]
[ WHERE условие ]
[ GROUP BY элемент_группирования [, ...] ]
[ HAVING условие ]
[ ORDER BY выражение
[ ASC | DESC | USING оператор ]
[ NULLS { FIRST | LAST } ] [, ...]
]
[ LIMIT { число | ALL } ]
[ OFFSET начало [ ROW | ROWS ] ]
[ FETCH { FIRST | NEXT } [ число ] { ROW | ROWS } { ONLY | WITH TIES } ]
А про более сложные варианты использования SELECT
мы поговорим на следующей лекции.