Раздел 8 — SQL: работа с данными (DML)

Добавление, изменение и удаление данных — включая всё, что может пойти не так

Прогресс курса Раздел 8 из 13

Что вы освоите в этом разделе

6 академических часов: 1 час теории, 5 часов практики. Все примеры — на учебной базе «Социальная сеть».
01

Что физически происходит при изменении данных

С точки зрения реляционной модели INSERT, UPDATE и DELETE — операции над множеством кортежей. С точки зрения хранения всё сложнее, и это объясняет ряд неочевидных вещей.

Механика

  • UPDATE не изменяет строку на месте: создаётся новая версия, старая помечается устаревшей.
  • DELETE не освобождает место: строка помечается мёртвой.
  • Место переиспользуется после VACUUM — механизм разобран в разделе 6.
  • Отсюда: массовое обновление таблицы удваивает её размер на диске до очистки.
Практическое следствие для больших таблиц: обновление десяти миллионов строк одной командой создаёт десять миллионов новых версий, держит долгую транзакцию и блокирует очистку. Такие обновления делают порциями — по 10–50 тысяч строк с фиксацией между порциями.
02

INSERT

-- Одна строка
INSERT INTO posts (community_id, title, score)
VALUES (2, 'challenge code sprint', 750);

-- Несколько строк одной командой — быстрее, чем несколько INSERT
INSERT INTO posts (community_id, title, score) VALUES
    (2, 'analysis esports stats', 850),
    (3, 'guide studio lighting',  920),
    (5, 'review street food',     640);

-- INSERT ... SELECT — массовое добавление из запроса
-- Каждому пользователю — лайк нового поста
INSERT INTO likes (user_id, post_id, liked_at)
SELECT u.id,
       (SELECT id FROM posts WHERE title = 'challenge code sprint'),
       DATE '2026-02-25'
FROM   users u;
Никогда не вычисляйте первичный ключ как (SELECT max(id) + 1 FROM ...). Между чтением максимума и вставкой другой клиент вставит свою строку — и вы получите нарушение уникальности или, что хуже, перезапись. Это гонка, которая на тестах не воспроизводится, а в бою случается регулярно. Ключи выдаёт IDENTITY или последовательность — они атомарны по построению.

RETURNING — забрать результат сразу

Расширение PostgreSQL, избавляющее от второго запроса за только что вставленными данными.

-- Узнать сгенерированный id
INSERT INTO posts (community_id, title, score)
VALUES (1, 'tutorial SQL joins', 800)
RETURNING id, title;

-- Работает и для UPDATE, и для DELETE
UPDATE posts SET score = score * 1.1
WHERE  community_id = 1
RETURNING id, title, score;

UPSERT: ON CONFLICT

Задача «вставить, а если такая строка есть — обновить» решается одной атомарной командой. Попытка решить её через SELECT, затем INSERT или UPDATE — снова гонка.

-- Вставить или обновить бонус
INSERT INTO user_bonuses (user_id, community_id, bonus)
VALUES (1, 2, 15.0)
ON CONFLICT (user_id, community_id)
DO UPDATE SET bonus = EXCLUDED.bonus,
              granted_at = now();

-- Вставить, а дубликат молча пропустить
INSERT INTO likes (user_id, post_id, liked_at)
VALUES (1, 3, DATE '2026-03-01')
ON CONFLICT DO NOTHING;
Псевдотаблица EXCLUDED содержит строку, которую вы пытались вставить. Это позволяет писать сложную логику слияния: например, bonus = greatest(user_bonuses.bonus, EXCLUDED.bonus) — «оставить больший из двух». ON CONFLICT требует ограничения UNIQUE или PRIMARY KEY по указанным столбцам — без него конфликт не с чем сопоставить.
03

UPDATE

-- Простое обновление
UPDATE posts
SET    score = score * 1.1
WHERE  title = 'challenge code sprint';

-- Несколько столбцов сразу
UPDATE users
SET    city = 'Kazan',
       age  = age + 1
WHERE  id = 5;

-- UPDATE ... FROM: обновление по данным другой таблицы
-- Снизить score постов сообщества GamersHub на 10%
UPDATE posts p
SET    score = round(p.score * 0.9)
FROM   communities c
WHERE  c.id = p.community_id
  AND  c.name = 'GamersHub';

-- Значение из подзапроса
UPDATE communities c
SET    rating = (
    SELECT round(avg(p.score) / 250.0, 1)
    FROM   posts p
    WHERE  p.community_id = c.id
);
UPDATE без WHERE изменяет всю таблицу. Ошибка занимает секунду, последствия — весь день. Выработайте привычку: сначала пишете SELECT с тем же WHERE, смотрите, сколько строк попало, и только потом превращаете его в UPDATE. На боевой базе — в транзакции: BEGIN, команда, проверка числа затронутых строк, затем COMMIT или ROLLBACK.
-- Рабочая привычка: сначала посмотреть, что попадёт под изменение
SELECT count(*) FROM posts WHERE title LIKE '%tutorial%';

BEGIN;
UPDATE posts SET score = round(score * 0.95)
WHERE  title LIKE '%tutorial%';
-- UPDATE 8  ← столько строк изменено, ожидали именно столько?
COMMIT;
04

DELETE и мягкое удаление

-- Удаление по условию
DELETE FROM likes
WHERE  liked_at < DATE '2026-01-05';

-- DELETE ... USING: удаление по данным другой таблицы
-- Удалить лайки постов сообщества SportLife
DELETE FROM likes l
USING  posts p, communities c
WHERE  p.id = l.post_id
  AND  c.id = p.community_id
  AND  c.name = 'SportLife';

-- Забрать удалённое перед исчезновением
DELETE FROM likes
WHERE  liked_at < DATE '2026-01-05'
RETURNING *;

В приложениях данные чаще не удаляют, а помечают удалёнными — это мягкое удаление. Так сохраняется история и остаются работоспособными ссылки из других таблиц.

ALTER TABLE posts ADD COLUMN deleted_at timestamptz;

-- «Удаление»
UPDATE posts SET deleted_at = now() WHERE id = 7;

-- Все запросы приложения теперь обязаны учитывать флаг
SELECT * FROM posts WHERE deleted_at IS NULL;

-- Уникальность — только среди живых строк
CREATE UNIQUE INDEX uq_posts_title_alive
    ON posts (community_id, title)
    WHERE deleted_at IS NULL;
Цена мягкого удаления: каждый запрос обязан помнить про фильтр. Один забытый WHERE deleted_at IS NULL — и удалённые записи всплывают в отчёте. Практическое решение — представление или политика RLS, отдающие только живые строки, а таблицу оставить для служебного доступа.
DELETETRUNCATE
Условие WHEREданет, только целиком
Скорость на большой таблицемедленномгновенно
Освобождает место сразунет, нужен VACUUMда
Срабатывают триггеры на строкуданет
RETURNINGданет
Откатывается транзакциейдада
05

Подзапросы

Подзапрос — запрос внутри запроса. Различают по тому, что он возвращает и зависит ли от внешнего запроса.

-- Скалярный: возвращает одно значение
SELECT title, score,
       score - (SELECT avg(score) FROM posts) AS diff_from_avg
FROM   posts;

-- Список значений: IN
SELECT name FROM users
WHERE  id IN (SELECT user_id FROM likes WHERE liked_at > DATE '2026-01-10');

-- Коррелированный: выполняется для каждой строки внешнего запроса
SELECT c.name,
       (SELECT count(*) FROM posts p WHERE p.community_id = c.id) AS posts_count
FROM   communities c;

-- EXISTS: проверка существования, останавливается на первой найденной строке
SELECT c.name FROM communities c
WHERE  EXISTS (SELECT 1 FROM posts p WHERE p.community_id = c.id);

-- Подзапрос в FROM (производная таблица)
SELECT t.community_id, t.cnt
FROM   (SELECT community_id, count(*) AS cnt
        FROM   posts GROUP BY community_id) t
WHERE  t.cnt > 2;
Ловушка NOT IN с NULL. Если подзапрос вернёт хотя бы одно значение NULL, NOT IN не вернёт ни одной строки. Причина в трёхзначной логике из раздела 2: сравнение с NULL даёт UNKNOWN, а NOT UNKNOWN — тоже UNKNOWN, и условие никогда не становится истинным. Запрос не падает и не предупреждает — просто молча возвращает пустоту.
-- Опасно: если manager_id где-то NULL — результат всегда пуст
SELECT name FROM users
WHERE  id NOT IN (SELECT manager_id FROM orders);

-- Безопасно: NOT EXISTS не подвержен этой проблеме
SELECT u.name FROM users u
WHERE  NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.manager_id = u.id
);

-- Либо явно отсечь NULL
SELECT name FROM users
WHERE  id NOT IN (SELECT manager_id FROM orders WHERE manager_id IS NOT NULL);
Правило: NOT EXISTS вместо NOT IN всегда, если только вы не уверены, что NULL невозможен. Для IN проблема не так остра, но EXISTS обычно и быстрее — он прекращает поиск на первом совпадении.
06

Общие табличные выражения (CTE)

WITH позволяет назвать подзапрос и обращаться к нему как к таблице. Главная ценность — читаемость: запрос из вложенных подзапросов превращается в последовательность понятных шагов.

-- Активность пользователей: лайки и просмотры вместе
WITH user_likes AS (
    SELECT user_id, count(*) AS likes_count
    FROM   likes
    GROUP BY user_id
),
user_views AS (
    SELECT user_id, count(*) AS views_count
    FROM   views
    GROUP BY user_id
)
SELECT u.name,
       coalesce(l.likes_count, 0) AS likes,
       coalesce(v.views_count, 0) AS views
FROM   users u
LEFT JOIN user_likes l ON l.user_id = u.id
LEFT JOIN user_views v ON v.user_id = u.id
ORDER BY likes DESC, u.name;
До PostgreSQL 12 каждое CTE всегда материализовалось — вычислялось целиком во временную таблицу, и условия из внешнего запроса внутрь не проникали. Это делало CTE «барьером оптимизации». С версии 12 планировщик может встроить CTE в основной запрос, а управлять этим можно явно: WITH x AS MATERIALIZED (...) или NOT MATERIALIZED. Материализация полезна, когда CTE используется несколько раз или содержит дорогой расчёт.

Модифицирующие CTE

В PostgreSQL внутри WITH можно писать INSERT, UPDATE и DELETE с RETURNING. Это позволяет перенести данные между таблицами одной атомарной командой.

-- Перенести старые лайки в архив: удалить и вставить за один проход
WITH moved AS (
    DELETE FROM likes
    WHERE  liked_at < DATE '2026-01-05'
    RETURNING *
)
INSERT INTO likes_archive (id, user_id, post_id, liked_at)
SELECT id, user_id, post_id, liked_at FROM moved;
Все части модифицирующего CTE видят один и тот же снимок данных — тот, что был на начало команды. Изменения одной ветви не видны другой. Поэтому нельзя в одном запросе вставить строку и тут же в другой ветви на неё сослаться: её там ещё нет. Порядок выполнения ветвей тоже не гарантирован.

Рекурсивные CTE

Обход иерархий и графов: дерево категорий, структура подчинения, цепочка комментариев.

-- Дерево категорий товаров
WITH RECURSIVE tree AS (
    -- Якорь: корни дерева
    SELECT id, name, parent_id, 1 AS level, name::text AS path
    FROM   categories
    WHERE  parent_id IS NULL

    UNION ALL

    -- Рекурсивная часть: дети уже найденных
    SELECT c.id, c.name, c.parent_id, t.level + 1, t.path || ' / ' || c.name
    FROM   categories c
    JOIN   tree t ON c.parent_id = t.id
    WHERE  t.level < 10          -- защита от зацикливания
)
SELECT repeat('  ', level - 1) || name AS tree_view, path
FROM   tree
ORDER BY path;
Если в данных есть цикл — категория ссылается на саму себя через цепочку родителей — рекурсивный запрос будет выполняться, пока не кончится память. Ограничение по уровню вложенности обязательно. Второй способ защиты: накапливать пройденные идентификаторы в массиве и проверять, что очередной узел в нём ещё не встречался.
07

Массовая загрузка: COPY

Для загрузки больших объёмов INSERT не подходит — он обрабатывает строки по одной. Команда COPY работает на порядок быстрее.

-- Загрузка из файла (файл читает сервер, нужны права суперпользователя)
COPY users (id, name, age, gender, city)
FROM '/tmp/users.csv'
WITH (FORMAT csv, HEADER true);

-- Выгрузка
COPY (SELECT * FROM posts WHERE score > 800)
TO '/tmp/top_posts.csv'
WITH (FORMAT csv, HEADER true);
# \copy в psql — файл читает клиент, права суперпользователя не нужны
psql -d socialnet -c "\copy users FROM 'users.csv' WITH (FORMAT csv, HEADER true)"
Разница между COPY и \copy — источник файла. COPY читает файл на сервере и требует прав суперпользователя. \copy — метакоманда psql: файл читает клиент и передаёт содержимое по соединению. В повседневной работе почти всегда нужен второй. В pgAdmin то же самое делает диалог Import/Export на таблице — он под капотом вызывает \copy.

Приём для грязных данных: загрузить во временную таблицу «как есть», проверить, разложить по целевым таблицам.

CREATE TEMP TABLE import_raw (
    name text, age text, city text   -- всё текстом, чтобы загрузилось что угодно
);

-- \copy import_raw FROM 'data.csv' WITH (FORMAT csv, HEADER true)

-- Проверить, что не проходит контроль
SELECT * FROM import_raw
WHERE  age !~ '^[0-9]+$' OR name IS NULL;

-- Перенести только корректное
INSERT INTO users (name, age, city)
SELECT name, age::integer, city
FROM   import_raw
WHERE  age ~ '^[0-9]+$';
08

Безопасность изменяющих запросов

SQL-инъекция возникает, когда данные пользователя склеиваются с текстом запроса. Единственная надёжная защита — параметризованные запросы: данные передаются отдельно от текста и не могут стать частью команды.

-- Так делать нельзя ни при каких обстоятельствах
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

-- Ввод: ' OR '1'='1' --   → вернёт всю таблицу
-- Ввод: '; DROP TABLE users; --  → зависит от прав роли приложения

-- Правильно: параметр передаётся отдельно
cursor.execute("SELECT * FROM users WHERE name = %s", [user_input])
Экранирование кавычек вручную и «чёрные списки» опасных слов не работают — способов обойти их слишком много. Вспомните раздел 6: роль приложения без права DROP превращает катастрофу в утечку. Обе меры нужны вместе, ни одна не заменяет другую.

Правила работы с изменяющими запросами

  • Перед UPDATE и DELETESELECT count(*) с тем же условием.
  • На боевой базе — в транзакции, с проверкой числа затронутых строк до COMMIT.
  • Массовые операции — порциями, а не одной командой на миллионы строк.
  • Ключи выдаёт база, а не приложение подсчётом максимума.
  • «Проверить и вставить» — это ON CONFLICT, а не SELECT с последующим INSERT.
  • Параметризация запросов — всегда, включая «безопасные» внутренние скрипты.

Ключевые выводы раздела

Запомните главное

  • UPDATE создаёт новую версию строки — массовое обновление удваивает размер таблицы до VACUUM
  • max(id) + 1 для ключа — гонка; ключи выдаёт IDENTITY
  • RETURNING избавляет от повторного запроса за изменёнными данными
  • ON CONFLICT — атомарная замена связке «проверить и вставить»
  • UPDATE без WHERE меняет всю таблицу: сначала SELECT, потом изменение
  • Мягкое удаление требует фильтра в каждом запросе — надёжнее прятать его в представление
  • NOT IN с NULL в подзапросе молча возвращает пустоту; используйте NOT EXISTS
  • CTE делают запрос читаемым; с версии 12 они не всегда барьер оптимизации
  • Ветви модифицирующего CTE видят один снимок и не видят изменений друг друга
  • Рекурсивный запрос обязан иметь ограничение глубины
  • \copy вместо COPY, когда файл на стороне клиента
  • От инъекций защищает только параметризация — и урезанные права роли приложения
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 7: DDL Раздел 9: Выборка и фильтрация