VIEW (представление) — это именованный SQL-запрос, сохранённый в базе данных. Обращаться к нему можно как к таблице. Разберём что это даёт, когда использовать и когда избегать.
Что такое VIEW
-- Создать VIEW
CREATE VIEW active_users AS
SELECT id, email, name, created_at, streak_days
FROM users
WHERE is_active = true AND deleted_at IS NULL;
-- Использовать как таблицу
SELECT * FROM active_users WHERE streak_days > 7;
SELECT COUNT(*) FROM active_users;
VIEW не хранит данные — каждый раз при обращении выполняется исходный запрос.
Зачем нужны VIEW
1. Упрощение сложных запросов
-- Без VIEW — повторяющийся сложный запрос
SELECT u.name, u.email, COUNT(o.id) AS order_count, SUM(o.amount) AS total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed'
GROUP BY u.id, u.name, u.email;
-- С VIEW — один раз создать, многократно использовать
CREATE VIEW user_stats AS
SELECT
u.id,
u.name,
u.email,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed'
GROUP BY u.id, u.name, u.email;
-- Использование
SELECT * FROM user_stats WHERE total_spent > 10000;
2. Безопасность — скрыть данные
-- Скрыть чувствительные поля
CREATE VIEW public_users AS
SELECT id, name, avatar_url, bio, created_at
FROM users
-- Не включаем: email, phone, password_hash, payment_data
WHERE is_active = true;
-- Дать доступ только к VIEW
GRANT SELECT ON public_users TO readonly_role;
REVOKE SELECT ON users FROM readonly_role;
3. Абстракция от схемы
-- Если переименовали таблицу — VIEW остался совместимым
CREATE VIEW orders AS SELECT * FROM purchase_orders;
-- Старый код продолжает работать через VIEW
CREATE OR REPLACE VIEW
-- Обновить VIEW без DROP + CREATE
CREATE OR REPLACE VIEW active_users AS
SELECT id, email, name, created_at, streak_days, elo_rating
FROM users
WHERE is_active = true;
-- Ограничение: нельзя изменить порядок или тип столбцов
-- В таком случае нужно DROP + CREATE
DROP VIEW
DROP VIEW active_users;
DROP VIEW IF EXISTS active_users;
DROP VIEW active_users CASCADE; -- вместе с зависимыми объектами
Обновляемые VIEW
Простые VIEW (без JOIN, агрегации, DISTINCT) поддерживают INSERT/UPDATE/DELETE:
CREATE VIEW my_tasks AS
SELECT id, title, status, due_date
FROM tasks
WHERE assigned_to = current_user;
-- Работает если VIEW обновляемый:
UPDATE my_tasks SET status = 'done' WHERE id = 5;
INSERT INTO my_tasks (title, due_date) VALUES ('Новая задача', '2026-04-01');
Условия для обновляемости:
- Один источник (один FROM без JOIN)
- Нет DISTINCT, GROUP BY, HAVING, LIMIT, OFFSET
- Нет агрегатных и оконных функций
- Нет UNION / EXCEPT / INTERSECT
WITH CHECK OPTION
Запрещает INSERT/UPDATE, нарушающие условие WHERE:
CREATE VIEW my_tasks AS
SELECT * FROM tasks WHERE assigned_to = current_user
WITH CHECK OPTION;
-- Ошибка: assigned_to не current_user
INSERT INTO my_tasks (title, assigned_to) VALUES ('Чужая задача', 99);
Материализованный VIEW
Обычный VIEW выполняет запрос при каждом обращении. Материализованный — хранит результат.
-- Создать материализованный VIEW
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders_count,
SUM(amount) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY 1
ORDER BY 1;
-- Обновить данные
REFRESH MATERIALIZED VIEW monthly_stats;
-- Обновить без блокировки чтения (но нужен UNIQUE индекс)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats;
-- Индексы на матвью работают!
CREATE INDEX ON monthly_stats (month);
Когда использовать материализованный VIEW:
- Тяжёлый запрос (COUNT миллионов строк, сложные JOIN)
- Данные не должны быть свежими в реальном времени (обновляется раз в час/день)
- Нужны индексы на результате
Когда не использовать:
- Данные должны быть актуальны сразу после изменений
- Запрос быстрый — нет смысла платить за хранение
Производительность VIEW
VIEW не кешируют — каждый запрос пересчитывается:
-- Плохо: сложный VIEW поверх другого сложного VIEW
CREATE VIEW stats AS SELECT * FROM heavy_query_view WHERE ...;
PostgreSQL применяет оптимизацию «view merging» — встраивает запрос VIEW в основной. Но с несколькими уровнями вложенности оптимизатор может запутаться.
Проверяйте EXPLAIN ANALYZE при работе со сложными VIEW:
EXPLAIN ANALYZE SELECT * FROM active_users WHERE streak_days > 7;
WITH (CTE) vs VIEW
-- CTE: временная, в рамках одного запроса
WITH active AS (
SELECT * FROM users WHERE is_active = true
)
SELECT * FROM active WHERE ...;
-- VIEW: постоянная, видна всем в базе
CREATE VIEW active_users AS
SELECT * FROM users WHERE is_active = true;
| VIEW | CTE | |
|---|---|---|
| Область видимости | Вся база | Один запрос |
| Переиспользование | ✅ | ❌ |
| Права доступа | ✅ | ❌ |
| Хранение данных | ❌ (обычная) | ❌ |
| Хранение данных | ✅ (матвью) | ❌ |
Практические примеры
Дашборд аналитика
CREATE VIEW analytics.daily_metrics AS
SELECT
DATE_TRUNC('day', created_at) AS day,
COUNT(DISTINCT user_id) AS dau,
COUNT(*) AS events,
COUNT(*) FILTER (WHERE event = 'purchase') AS purchases
FROM user_events
GROUP BY 1;
Публичный профиль без чувствительных данных
CREATE VIEW public_profiles AS
SELECT
id,
display_name,
avatar_url,
bio,
elo_rating,
streak_days,
created_at::date AS member_since
FROM users
WHERE is_active = true AND is_public = true;
Итог
| Обычный VIEW | Материализованный VIEW | |
|---|---|---|
| Хранит данные | ❌ | ✅ |
| Актуальность | Всегда свежий | Нужен REFRESH |
| Индексы | ❌ | ✅ |
| INSERT/UPDATE | Иногда | ❌ |
| Для чего | Абстракция, безопасность | Тяжёлые агрегаты |
VIEW — мощный инструмент организации кода и разграничения доступа. Материализованный VIEW — решение для медленных аналитических запросов с допустимой задержкой обновления.