Общие табличные выражения (CTE)
Общее табличное выражение (Common Table Expression, CTE) — это временный набор результатов, определенный в рамках выполнения SQL-запроса. CTE позволяют упростить сложные запросы, разбивая их на более простые и удобные для обработки части. Они также помогают оптимизировать выполнение запроса, когда к одному и тому же набору данных необходимо обращаться несколько раз в рамках этого запроса.
Данная статья описывает, как использовать CTE в SELECT-запросах.
Для SELECT
-запросов можно также использовать команды INSERT
, UPDATE
и DELETE
внутри CTE.
Предварительные требования
Для выполнения команд, описанных в следующих разделах, подключитесь к мастер-хосту Greengage DB с помощью psql
, как описано в статье Подключение к Greengage DB с использованием psql.
Создайте новую базу данных и подключитесь к ней:
CREATE DATABASE marketplace;
\c marketplace
Нерекурсивные CTE
Синтаксис
Чтобы определить CTE, используйте выражение WITH
перед основным запросом:
WITH <cte_name> (<column_list>) AS (
<cte_subquery>
)
<main_query>;
где:
-
<cte_name>
— имя, используемое для обращения к CTE в основном запросе. -
<column_list>
— (опционально) список имен столбцов в результирующем наборе CTE. -
<cte_subquery>
— SQL-запрос, который определяет результирующий набор CTE. -
<main_query>
— основной SQL-запрос, использующий CTE.
Вы также можете определить несколько CTE в одном выражении WITH
, разделив их запятыми:
WITH <cte_name1> (<column_list1>) AS (
<cte_subquery1>
),
<cte_name2> (<column_list2>) AS (
<cte_subquery2>
),
...
<primary_query>;
Один подзапрос CTE
Чтобы выполнить примеры из этого раздела, создайте таблицу sales
:
CREATE TABLE sales
(
id INT,
date DATE,
product_name TEXT,
category TEXT,
quantity INT,
unit_price NUMERIC(8, 2)
)
WITH (appendoptimized = true)
DISTRIBUTED BY (id);
Добавьте данные:
INSERT INTO sales (id, date, product_name, category, quantity, unit_price)
VALUES (1, '2025-01-03', 'Laptop', 'Electronics', 1, 1200.00),
(2, '2025-01-04', 'Headphones', 'Electronics', 2, 150.00),
(3, '2025-01-05', 'Coffee Maker', 'Home', 1, 85.00),
(4, '2025-01-06', 'T-Shirt', 'Clothing', 3, 25.00),
(5, '2025-01-07', 'Desk Chair', 'Home', 1, 200.00),
(6, '2025-01-08', 'Smartphone', 'Electronics', 2, 800.00),
(7, '2025-01-09', 'Headphones', 'Electronics', 1, 150.00),
(8, '2025-01-10', 'T-Shirt', 'Clothing', 2, 25.00),
(9, '2025-01-11', 'Laptop', 'Electronics', 1, 1200.00);
Этот запрос определяет CTE с именем product_sales
для агрегирования данных о продажах товаров, а затем выбирает товары, отсортированные по общей выручке в порядке убывания:
WITH product_sales AS (SELECT product_name,
SUM(quantity) AS total_quantity,
SUM(quantity * unit_price) AS total_revenue
FROM sales
GROUP BY product_name)
SELECT product_name,
total_revenue
FROM product_sales
ORDER BY total_revenue DESC;
Результат выглядит следующим образом:
product_name | total_revenue --------------+--------------- Laptop | 2400.00 Smartphone | 1600.00 Headphones | 450.00 Desk Chair | 200.00 T-Shirt | 125.00 Coffee Maker | 85.00 (6 rows)
Этот запрос использует подзапрос product_sales
в выражении FROM
, который возвращает тот же результат, что и CTE выше:
SELECT product_name,
total_revenue
FROM (SELECT product_name,
SUM(quantity) AS total_quantity,
SUM(quantity * unit_price) AS total_revenue
FROM sales
GROUP BY product_name) AS product_sales
ORDER BY total_revenue DESC;
Несколько подзапросов CTE
Этот запрос определяет два CTE: category_sales
агрегирует выручку по категориям, а product_sales
агрегирует продажи по товарам.
Основной запрос объединяет эти CTE, чтобы отобразить выручку для каждого товара вместе с общей выручкой его категории:
WITH category_sales AS (SELECT category,
SUM(quantity * unit_price) AS category_revenue
FROM sales
GROUP BY category),
product_sales AS (SELECT product_name,
category,
SUM(quantity) AS total_quantity,
SUM(quantity * unit_price) AS total_revenue
FROM sales
GROUP BY product_name, category)
SELECT product_sales.product_name,
product_sales.category,
product_sales.total_revenue,
category_sales.category_revenue
FROM product_sales
JOIN category_sales ON product_sales.category = category_sales.category
ORDER BY product_sales.total_revenue DESC;
Результат должен выглядеть следующим образом:
product_name | category | total_revenue | category_revenue --------------+-------------+---------------+------------------ Laptop | Electronics | 2400.00 | 4450.00 Smartphone | Electronics | 1600.00 | 4450.00 Headphones | Electronics | 450.00 | 4450.00 Desk Chair | Home | 200.00 | 285.00 T-Shirt | Clothing | 125.00 | 125.00 Coffee Maker | Home | 85.00 | 285.00 (6 rows)
Если переписать этот запрос без выражения WITH
, получится более сложный запрос с двумя вложенными подзапросами в FROM
:
SELECT product_sales.product_name,
product_sales.category,
product_sales.total_revenue,
category_sales.category_revenue
FROM (SELECT sales.product_name,
sales.category,
SUM(sales.quantity) AS total_quantity,
SUM(sales.quantity * sales.unit_price) AS total_revenue
FROM sales
GROUP BY sales.product_name, sales.category) AS product_sales
JOIN (SELECT sales.category,
SUM(sales.quantity * sales.unit_price) AS category_revenue
FROM sales
GROUP BY sales.category) AS category_sales
ON product_sales.category = category_sales.category
ORDER BY product_sales.total_revenue DESC;
Рекурсивные CTE
Синтаксис
Рекурсивные CTE позволяют выполнять итеративные вычисления внутри запроса и часто используются для обработки иерархических данных. Рекурсивное CTE имеет следующий синтаксис:
WITH RECURSIVE <cte_name> (<column_list>) AS (
-- Нерекурсивная часть: запускается один раз
<non_recursive_term>
UNION [ALL]
-- Рекурсивная часть: ссылается на <cte_name>, выполняется многократно
<recursive_term>
)
<main_query>;
где:
-
<cte_name>
— имя CTE; используется в рекурсивной части и в основном запросе. -
<column_list>
— (опционально) список имен столбцов в результирующем наборе CTE. -
<non_recursive_term>
— базовый запрос, который формирует начальный набор результатов (выполняется один раз). -
<recursive_term>
— запрос, который ссылается на<cte_name>
и выполняется многократно, последовательно добавляя новые строки в результирующий набор, пока не перестанут появляться новые данные. Результат объединяется с базовым запросом с помощью оператора UNION илиUNION ALL
. -
<main_query>
— основной SQL-запрос, который использует итоговый результат рекурсивного CTE.
Greengage DB обрабатывает рекурсивные CTE следующим образом:
-
Выполняется нерекурсивная часть для получения начального набора результатов.
-
Выполняется рекурсивная часть, которая использует результаты предыдущего шага в качестве входных данных для генерации дополнительных строк.
-
Шаг 2 повторяется до тех пор, пока рекурсивная часть не перестанет возвращать новые строки.
-
Возвращается итоговый набор результатов — объединение всех строк, полученных в нерекурсивной и рекурсивной частях.
Последовательность чисел
Этот запрос использует рекурсивное CTE для генерации последовательности чисел от 1 до 5:
WITH RECURSIVE numbers AS (
-- Нерекурсивная часть
SELECT 1 AS n
UNION ALL
-- Рекурсивная часть
SELECT n + 1 FROM numbers WHERE n < 5
)
SELECT n FROM numbers;
Результат выглядит следующим образом:
n --- 1 2 3 4 5 (5 rows)
Накопительная сумма
В этом запросе используется рекурсивное CTE running_sum
для вычисления накопительной суммы для чисел от 1 до 5:
WITH RECURSIVE running_sum AS (
SELECT 1 AS n,
1 AS running_total
UNION ALL
SELECT n + 1,
running_total + n + 1
FROM running_sum
WHERE n < 5
)
SELECT n,
running_total
FROM running_sum;
Результат выглядит следующим образом:
n | running_total ---+--------------- 1 | 1 2 | 3 3 | 6 4 | 10 5 | 15 (5 rows)
Этот запрос вычисляет накопительную сумму количества товаров в таблице sales
с помощью рекурсивного CTE.
Вычисление начинается с определенной даты и последовательно добавляет количество товаров, проданных в каждый из дней, к накопительному итогу предыдущих дней:
WITH RECURSIVE cumulative_quantity AS (
SELECT date,
quantity,
quantity AS cumulative_total
FROM sales
WHERE date = '2025-01-03'
UNION ALL
SELECT sales.date,
sales.quantity,
cumulative_quantity.cumulative_total + sales.quantity
FROM sales
JOIN cumulative_quantity ON sales.date = cumulative_quantity.date + INTERVAL '1 day'
)
SELECT date,
quantity,
cumulative_total
FROM cumulative_quantity;
Результат должен выглядеть следующим образом:
date | quantity | cumulative_total ------------+----------+------------------ 2025-01-03 | 1 | 1 2025-01-04 | 2 | 3 2025-01-05 | 1 | 4 2025-01-06 | 3 | 7 2025-01-07 | 1 | 8 2025-01-08 | 2 | 10 2025-01-09 | 1 | 11 2025-01-10 | 2 | 13 2025-01-11 | 1 | 14 (9 rows)
Иерархические данные
Чтобы выполнить примеры из этого раздела, создайте таблицу customers
:
CREATE TABLE customers
(
customer_id INT,
name TEXT,
referred_by INT,
region TEXT
)
WITH (appendoptimized = true)
DISTRIBUTED BY (customer_id);
Этот запрос добавляет данные о клиентах с информацией о реферальных связях:
INSERT INTO customers (customer_id, name, referred_by, region)
VALUES (1, 'Alice', NULL, 'North'),
(2, 'Bob', 1, 'North'),
(3, 'Charlie', 1, 'North'),
(4, 'David', 2, 'South'),
(5, 'Eve', 2, 'South'),
(6, 'Frank', 4, 'South'),
(7, 'Grace', 3, 'East');
/*
Alice
├── Bob
│ ├── David
│ │ └── Frank
│ └── Eve
└── Charlie
└── Grace
*/
Этот запрос использует рекурсивное CTE для поиска всех клиентов, которые были приглашены клиентом с customer_id
, равным 2
— как напрямую, так и через цепочку рекомендаций:
WITH RECURSIVE referrals(customer_id, name, referred_by) AS (
SELECT customer_id,
name,
referred_by
FROM customers
WHERE customer_id = 2
UNION ALL
SELECT customers.customer_id,
customers.name,
customers.referred_by
FROM customers
INNER JOIN referrals ON customers.referred_by = referrals.customer_id
)
SELECT * FROM referrals;
Порядок поиска
По умолчанию рекурсивные запросы возвращают результаты в порядке "сначала в ширину" (breadth-first).
При применении к таблице customers
поиск в порядке "сначала в ширину" обрабатывает всех непосредственных рефералов клиента, прежде чем переходить глубже по цепочке рекомендаций.
При порядке "сначала в глубину" (depth-first) запрос проходит по каждой цепочке рекомендаций как можно глубже, прежде чем перейти к следующему рефералу.
Чтобы изменить порядок обхода, необходимо создать массив path
, который отслеживает последовательность рефералов.
Этот запрос добавляет идентификатор каждого клиента в path
по мере выполнения рекурсии, формируя полную цепочку рефералов для каждой строки:
WITH RECURSIVE referrals(customer_id, name, referred_by, path) AS (
SELECT
customer_id,
name,
referred_by,
ARRAY[customer_id] AS path
FROM customers
WHERE customer_id = 2
UNION ALL
SELECT
customers.customer_id,
customers.name,
customers.referred_by,
referrals.path || customers.customer_id
FROM customers
INNER JOIN referrals ON customers.referred_by = referrals.customer_id
)
SELECT * FROM referrals;
Чтобы вывести результаты в порядке "сначала в глубину", добавьте ORDER BY path
во внешнем запросе:
WITH RECURSIVE referrals(customer_id, name, referred_by, path) AS (
SELECT
customer_id,
name,
referred_by,
ARRAY[customer_id] AS path
FROM customers
WHERE customer_id = 2
UNION ALL
SELECT
customers.customer_id,
customers.name,
customers.referred_by,
referrals.path || customers.customer_id
FROM customers
INNER JOIN referrals ON customers.referred_by = referrals.customer_id
)
SELECT * FROM referrals ORDER BY path;
Результаты этих запросов:
customer_id | name | referred_by | path -------------+-------+-------------+--------- 2 | Bob | 1 | {2} 4 | David | 2 | {2,4} 5 | Eve | 2 | {2,5} 6 | Frank | 4 | {2,4,6} (4 rows)
customer_id | name | referred_by | path -------------+-------+-------------+--------- 2 | Bob | 1 | {2} 4 | David | 2 | {2,4} 6 | Frank | 4 | {2,4,6} 5 | Eve | 2 | {2,5} (4 rows)
Выявление бесконечных циклов
В рекурсивных SQL-запросах рекурсивная часть должна в конечном итоге возвращать пустой результат, чтобы избежать бесконечного цикла.
Чтобы продемонстрировать, как обнаруживать и обрабатывать циклы, создайте цикл, обновив таблицу customers
следующим образом:
UPDATE customers
SET referred_by = 6
WHERE customer_id = 1;
Этот SQL-запрос использует рекурсивное CTE для обхода реферальных связей, начиная с клиента с ID, равным 2
, и выявляет циклы, отслеживая цепочки рекомендаций.
В запрос добавляется флаг cycle
, который устанавливается в true
, если клиент встречается в цепочке рекомендаций более одного раза:
WITH RECURSIVE referrals(customer_id, name, referred_by, path, cycle) AS (
SELECT
customer_id,
name,
referred_by,
ARRAY[customer_id] AS path,
FALSE AS cycle
FROM customers
WHERE customer_id = 2
UNION ALL
SELECT
customers.customer_id,
customers.name,
customers.referred_by,
referrals.path || customers.customer_id,
customers.customer_id = ANY(referrals.path) AS cycle -- Выявление цикла
FROM customers
INNER JOIN referrals ON customers.referred_by = referrals.customer_id
WHERE NOT referrals.cycle -- Остановка рекурсии, если обнаружен цикл
)
SELECT * FROM referrals;
customer_id | name | referred_by | path | cycle -------------+---------+-------------+---------------+------- 2 | Bob | 1 | {2} | f 5 | Eve | 2 | {2,5} | f 4 | David | 2 | {2,4} | f 6 | Frank | 4 | {2,4,6} | f 1 | Alice | 6 | {2,4,6,1} | f 3 | Charlie | 1 | {2,4,6,1,3} | f 2 | Bob | 1 | {2,4,6,1,2} | t 7 | Grace | 3 | {2,4,6,1,3,7} | f (8 rows)
Для обнаружения цикла по нескольким полям можно использовать массив строк для отслеживания обработанных комбинаций:
WITH RECURSIVE referrals AS (
SELECT
customer_id,
name,
referred_by,
region,
ARRAY[ROW(customer_id, region)] AS path,
FALSE AS cycle
FROM customers
WHERE customer_id = 2
UNION ALL
SELECT
customers.customer_id,
customers.name,
customers.referred_by,
customers.region,
referrals.path || ROW(customers.customer_id, customers.region),
ROW(customers.customer_id, customers.region) = ANY(referrals.path)
FROM customers
JOIN referrals ON customers.referred_by = referrals.customer_id
WHERE NOT referrals.cycle
)
SELECT * FROM referrals;
customer_id | name | referred_by | region | path | cycle -------------+---------+-------------+--------+--------------------------------------------------------------------------+------- 2 | Bob | 1 | North | {"(2,North)"} | f 4 | David | 2 | South | {"(2,North)","(4,South)"} | f 5 | Eve | 2 | South | {"(2,North)","(5,South)"} | f 6 | Frank | 4 | South | {"(2,North)","(4,South)","(6,South)"} | f 1 | Alice | 6 | North | {"(2,North)","(4,South)","(6,South)","(1,North)"} | f 2 | Bob | 1 | North | {"(2,North)","(4,South)","(6,South)","(1,North)","(2,North)"} | t 3 | Charlie | 1 | North | {"(2,North)","(4,South)","(6,South)","(1,North)","(3,North)"} | f 7 | Grace | 3 | East | {"(2,North)","(4,South)","(6,South)","(1,North)","(3,North)","(7,East)"} | f (8 rows)
Полезный прием для предотвращения бесконечных циклов при тестировании рекурсивных SQL-запросов — добавить выражение LIMIT
в основной запрос.
Например, без LIMIT
этот рекурсивный запрос будет выполняться бесконечно:
WITH RECURSIVE numbers AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM numbers
)
SELECT n FROM numbers LIMIT 10;
n ---- 1 2 3 4 5 6 7 8 9 10 (10 rows)
Отключение рекурсивных CTE
Рекурсивные CTE можно отключить, установив параметр конфигурации сервера gp_recursive_cte
в значение false
.
Эта команда отключает рекурсивные CTE для текущей сессии:
SET gp_recursive_cte = false;
Теперь вы можете проверить, что рекурсивные CTE отключены, например:
WITH RECURSIVE numbers AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 5
)
SELECT n FROM numbers;
Запрос должен завершиться с ошибкой:
RECURSIVE clauses in WITH queries are currently disabled
Повторное использование CTE
В Greengage DB GPORCA может автоматически переиспользовать результаты CTE, к которому обращаются несколько раз, в зависимости от оценки стоимости.
Планировщик Postgres с помощью параметра конфигурации gp_cte_sharing
управляет тем, будет ли CTE, используемый в запросе несколько раз, вычисляться заново для каждого использования:
-
Если
gp_cte_sharing
установлен вon
, планировщик вычисляет CTE один раз и использует результаты для всех обращений. -
Если
gp_cte_sharing
установлен вoff
, каждое обращение к CTE выполняется независимо, то есть вычисление повторяется для каждого использования.
Изменение данных
Для SELECT
-запросов можно использовать команды INSERT
, UPDATE
и DELETE
в выражении WITH
.
Это позволяет выполнять несколько операций в одном запросе.
Если основной запрос ссылается на команду изменения данных в выражении WITH
, эта команда выполняется ровно один раз и полностью завершается, независимо от того, использует ли основной запрос весь ее результат или только часть.
В отличие от этого, команда SELECT
в выражении WITH
выполняется только по мере необходимости, в зависимости от того, сколько данных запрашивает основной запрос.
Чтобы выполнить примеры из этого раздела, создайте таблицу stg_sales
:
CREATE TABLE stg_sales
(
id INT,
date DATE,
product_name TEXT,
category TEXT,
quantity INT,
unit_price NUMERIC(8, 2)
)
DISTRIBUTED BY (id);
Добавьте данные:
INSERT INTO stg_sales (id, date, product_name, category, quantity, unit_price)
VALUES (1, '2025-01-03', 'Laptop', 'Electronics', 1, 1200.00),
(2, '2025-01-04', 'Headphones', 'Electronics', 2, 150.00),
(3, '2025-01-05', 'Coffee Maker', 'Home', 1, 85.00),
(4, '2025-01-06', 'T-Shirt', 'Clothing', 3, 25.00),
(5, '2025-01-07', 'Desk Chair', 'Home', 1, 200.00),
(6, '2025-01-08', 'Smartphone', 'Electronics', 2, 800.00),
(7, '2025-01-09', 'Headphones', 'Electronics', 1, 150.00),
(8, '2025-01-10', 'T-Shirt', 'Clothing', 2, 25.00),
(9, '2025-01-11', 'Laptop', 'Electronics', 1, 1200.00);
Удаление
Этот SQL-запрос удаляет строки из таблицы stg_sales
с датами до 6 января 2025 года и возвращает данные об удаленных строках:
WITH deleted_sales AS (
DELETE FROM stg_sales
WHERE date < '2025-01-06'
RETURNING id, product_name, date)
SELECT *
FROM deleted_sales;
id | product_name | date ----+--------------+------------ 3 | Coffee Maker | 2025-01-05 1 | Laptop | 2025-01-03 2 | Headphones | 2025-01-04 (3 rows)
Обратите внимание, что изменяющие данные команды в выражении WITH
должны содержать RETURNING
, чтобы можно было сослаться на их результат в родительском запросе.
Выражение DELETE … RETURNING
не поддерживается для оптимизированных для добавления (AO) таблиц.
Обновление
Этот запрос увеличивает цену Laptop
на 10% в таблице stg_sales
и возвращает обновленные строки:
WITH updated_sales AS (
UPDATE stg_sales
SET unit_price = unit_price * 1.10
WHERE product_name = 'Laptop'
RETURNING id, product_name, unit_price)
SELECT *
FROM updated_sales;
id | product_name | unit_price ----+--------------+------------ 9 | Laptop | 1320.00 (1 row)
Этот запрос увеличивает цену Laptop
на 10% в таблице stg_sales
, однако основной запрос выбирает все строки из исходной таблицы, а не обновленные строки, возвращенные CTE:
WITH updated_sales AS (
UPDATE stg_sales
SET unit_price = unit_price * 1.10
WHERE product_name = 'Laptop'
RETURNING id, product_name, unit_price)
SELECT id, product_name, unit_price
FROM stg_sales;
id | product_name | unit_price ----+--------------+------------ 4 | T-Shirt | 25.00 7 | Headphones | 150.00 8 | T-Shirt | 25.00 5 | Desk Chair | 200.00 6 | Smartphone | 800.00 9 | Laptop | 1320.00 (6 rows)
Рекурсивные CTE
Чтобы выполнить примеры из этого раздела, создайте таблицу stg_customers
:
CREATE TABLE stg_customers
(
customer_id INT,
name TEXT,
referred_by INT,
region TEXT
)
DISTRIBUTED BY (customer_id);
Этот запрос добавляет данные о клиентах с информацией о реферальных связях:
INSERT INTO stg_customers (customer_id, name, referred_by, region)
VALUES (1, 'Alice', NULL, 'North'),
(2, 'Bob', 1, 'North'),
(3, 'Charlie', 1, 'North'),
(4, 'David', 2, 'South'),
(5, 'Eve', 2, 'South'),
(6, 'Frank', 4, 'South'),
(7, 'Grace', 3, 'East');
/*
Alice
├── Bob
│ ├── David
│ │ └── Frank
│ └── Eve
└── Charlie
└── Grace
*/
Рекурсивные ссылки в операторах, изменяющих данные, не допускаются. Однако вы можете включить команды изменения данных в основной запрос.
Этот запрос использует рекурсивное CTE для поиска всех клиентов, напрямую или косвенно приглашенных клиентом с customer_id
, равным 2
, а затем удаляет их из таблицы stg_customers
:
WITH RECURSIVE referrals(customer_id, name, referred_by) AS (
SELECT customer_id,
name,
referred_by
FROM stg_customers
WHERE customer_id = 2
UNION ALL
SELECT stg_customers.customer_id,
stg_customers.name,
stg_customers.referred_by
FROM stg_customers
INNER JOIN referrals ON stg_customers.referred_by = referrals.customer_id
)
DELETE FROM stg_customers
WHERE customer_id IN (SELECT customer_id FROM referrals);
Чтобы проверить, что записи о клиентах удалены, выполните запрос к таблице stg_customers
:
SELECT *
FROM stg_customers;
Результат выглядит следующим образом:
customer_id | name | referred_by | region -------------+---------+-------------+-------- 3 | Charlie | 1 | North 7 | Grace | 3 | East 1 | Alice | | North (3 rows)