List comprehensions, slicing и dot-оператор
SQL исторически плохо работает с коллекциями внутри строки. Если в колонке лежит список чисел, преобразовать каждый элемент стандартными средствами — это UNNEST, обработка развёрнутых строк и обратная сборка через агрегат. Три шага ради операции, которая в любом языке программирования — одна строка. То же со вложенными структурами: достать поле из STRUCT без удобного синтаксиса доступа неудобно.
DuckDB заимствует решение прямо из Python. List comprehensions (списочные выражения), slicing (срезы с отрицательными индексами) и dot-оператор для доступа к полям делают работу с LIST и STRUCT такой же лаконичной, как в Python. Это не отдельный «процедурный» язык поверх SQL — это выражения, которые движок исполняет векторизованно, как и любые другие. В этом уроке разберём все три и заодно зафиксируем актуальный синтаксис лямбд.
List comprehensions
Списочное выражение строит новый список из существующего: применяет преобразование к каждому элементу и опционально отфильтровывает часть. Синтаксис скопирован у Python почти дословно:
-- Удвоить каждый элемент списка
SELECT [x * 2 for x in [1, 2, 3, 4]] AS doubled;
-- результат: [2, 4, 6, 8]
-- С фильтром: только чётные, затем удвоить
SELECT [x * 2 for x in [1, 2, 3, 4, 5, 6] if x % 2 = 0] AS r;
-- результат: [4, 8, 12]
Структура — [выражение for переменная in список if условие]. for ... in перебирает элементы, выражение слева вычисляется для каждого, if (опционально) оставляет только подходящие. Читается как «список из x*2 для каждого x из исходного списка, где x чётное».
Применяется это к колонкам-спискам так же, как к литералам:
-- Таблица baskets: id INT, prices INT[]
-- Применить скидку 10% к каждой цене в каждой корзине
SELECT
id,
[round(p * 0.9, 2) for p in prices] AS discounted
FROM baskets;
Под капотом list comprehension — это синтаксический сахар над функциями list_transform и list_filter. Запрос выше эквивалентен list_transform(prices, lambda p: round(p * 0.9, 2)). DuckDB разворачивает comprehension в эти функции на этапе биндинга, а исполняются они векторизованно — без построчного интерпретатора, без UNNEST и обратной сборки.
Синтаксис лямбд: актуальная форма
Лямбды нужны для list-функций (list_transform, list_filter, list_reduce) и для COLUMNS(). С версии DuckDB 1.5 актуальный синтаксис лямбды — питоновский, со словом lambda и двоеточием:
-- Актуальная форма (DuckDB 1.5+): lambda параметр: тело
SELECT list_transform([1, 2, 3], lambda x: x + 1) AS r;
-- результат: [2, 3, 4]
SELECT list_filter([1, -2, 3, -4], lambda x: x > 0) AS positives;
-- результат: [1, 3]
Раньше DuckDB использовал стрелочную форму x -> x + 1. В версии 1.5 стрелочный синтаксис объявлен устаревшим (deprecated). Причина — конфликт: стрелка -> уже занята оператором извлечения из JSON (json_col -> '$.field'), и совмещение двух смыслов одного оператора создавало неоднозначность парсинга. Питоновская форма lambda x: устраняет конфликт и заодно знакома большинству. Поведением управляет настройка lambda_syntax, но в новом коде стоит сразу писать lambda-форму — это то, что будет жить дальше.
-- Лямбда с двумя параметрами: list_reduce сворачивает список
SELECT list_reduce([1, 2, 3, 4], lambda acc, x: acc + x) AS total;
-- результат: 10
Стрелочная форма x -> x + 1 всё ещё может работать в зависимости от настройки lambda_syntax, но она устарела с DuckDB 1.5 и будет удалена. В новом коде используйте только питоновскую форму lambda x: x + 1. Если видите стрелочную форму в старых примерах из интернета — это признак, что пример написан до 1.5.
Slicing: срезы и отрицательные индексы
Slicing достаёт подсписок (или подстроку) по диапазону позиций. Синтаксис — квадратные скобки с двоеточием, как в Python: list[начало:конец].
Первая особенность, к которой надо привыкнуть: в DuckDB индексация списков начинается с 1, а не с 0. list[1] — первый элемент. Срез list[start:end] включает обе границы.
-- Элементы со 2-го по 4-й включительно
SELECT ([10, 20, 30, 40, 50])[2:4] AS slice;
-- результат: [20, 30, 40]
Вторая особенность — отрицательные индексы, отсчёт с конца. [-1] — последний элемент, [-2] — предпоследний:
-- Последний элемент
SELECT ([10, 20, 30, 40, 50])[-1] AS last_elem;
-- результат: 50
-- Последние три элемента: от -3 до конца
SELECT ([10, 20, 30, 40, 50])[-3:] AS last_three;
-- результат: [30, 40, 50]
-- Всё, кроме последнего элемента
SELECT ([10, 20, 30, 40, 50])[:-2] AS without_last;
-- результат: [10, 20, 30, 40]
Пропущенная граница означает «до конца» ([2:]) или «с начала» ([:3]). Отрицательные индексы убирают типовую боль SQL: «достать последний элемент» без них требует знать длину списка (list[len(list)]), а [-1] работает независимо от длины.
Тот же синтаксис среза работает для строк — строка ведёт себя как список символов:
-- Первые 3 символа и последние 4
SELECT ('analytics')[1:3] AS prefix, ('analytics')[-4:] AS suffix;
-- prefix: 'ana', suffix: 'tics'
| Срез | Что возвращает |
|---|---|
xs[2:4] | элементы со 2-го по 4-й включительно |
xs[-1] | последний элемент |
xs[-3:] | последние три элемента |
xs[:3] | первые три элемента |
xs[:-1] | все, кроме последнего |
Dot-оператор для struct и chaining
STRUCT — это запись с именованными полями (детально разбирается в модуле про систему типов). Доступ к полю STRUCT в DuckDB — через точку, как к атрибуту объекта в Python:
-- Колонка address типа STRUCT(city VARCHAR, zip VARCHAR)
SELECT
address.city AS city,
address.zip AS zip
FROM customers;
address.city достаёт поле city из структуры address. Точечный доступ можно вкладывать для структур внутри структур: event.payload.user_id спускается на два уровня. Это намного читаемее, чем функция извлечения struct_extract(address, 'city') — хотя под капотом это она и есть.
Второе применение точки — chaining функций. Любую функцию f(x, ...) можно вызвать как x.f(...) — значение слева от точки становится первым аргументом функции. Это превращает вложенные вызовы в читаемую цепочку:
-- Вложенная запись: трудно читать изнутри наружу
SELECT trim(upper(replace(name, '_', ' ')));
-- Цепочка через точку: читается слева направо, в порядке применения
SELECT name.replace('_', ' ').upper().trim();
Обе строки делают одно и то же. name.replace('_', ' ') — это replace(name, '_', ' '). Результат идёт дальше в .upper(), то есть upper(...), и так далее. Цепочка читается в том порядке, в котором операции выполняются — слева направо, — а не «изнутри наружу», как вложенные вызовы.
Chaining и точечный доступ к struct используют один и тот же оператор ., и DuckDB различает их по контексту: если слева STRUCT и справа имя его поля — это доступ к полю; если справа вызов функции — это chaining. Сомневаетесь — всегда можно вернуться к явной форме struct_extract(s, 'field') и f(x, ...), она полностью эквивалентна.
Все четыре конструкции — comprehensions, lambda-форма, slicing, dot-оператор — объединяет одно: они приносят в SQL эргономику языка программирования, не превращая его в процедурный язык. Это по-прежнему декларативные выражения, которые оптимизатор видит насквозь и исполняет векторизованно. Вы просто пишете их так же компактно, как написали бы в Python.
Попробуй сам
Создайте таблицу:
CREATE TABLE baskets AS
SELECT * FROM (VALUES
(1, [100, 200, 50, 300]),
(2, [80, 120]),
) t(id, prices);
Задания:
- Через list comprehension постройте для каждой корзины список цен с 10-процентной скидкой.
- Через list comprehension с
ifоставьте в каждой корзине только цены больше 100. - Перепишите задание 1 явно через
list_transformс лямбдой в актуальной формеlambda x: .... Убедитесь в идентичности результата. - Для каждой корзины через slicing достаньте последние две цены (
prices[-2:]) и всё, кроме первой цены (prices[2:]). - Возьмите строку
'data_engineering'и через chaining приведите её к виду'DATA ENGINEERING': замените подчёркивание на пробел и переведите в верхний регистр. Запишите то же самое вложенными вызовами и сравните читаемость.