Перейти к содержанию

CRUD заявок в PostgreSQL

Пул позволяет читать отдельную строку PostgreSQL. Теперь вернёмся к HTTP-приложению DeskQueue и заменим временное хранилище постоянным. Контроллеры уже отделены от доступа к данным, поэтому изменение должно затронуть прежде всего модуль хранилища. Список, создание, чтение и изменение заявки сохранят прежние адреса. Отличие станет видно после перезапуска: новая заявка остаётся в базе.

Исходное состояние — применённая схема из урока 10 и подключение из урока 11. В архиве откройте stages/12. В этом состоянии приложение пока учебное и не требует входа пользователя. Поле author_id остаётся пустым. Не переносите такой промежуточный API в публичное окружение: следующие главы последовательно добавят проверку ввода, авторизацию и окончательный жизненный цикл.

Операция над строкой

Хранилище tickets.js содержит функции, которые возвращают данные, а не объект ответа Express. Для создания передаём только заголовок; исходное состояние open назначает схема. Таким образом, клиент не может в обход очереди сразу создать закрытую заявку. Полное определение функции выглядит так:

import { pool } from './db.js';
export async function createTicket(title, authorId = null) {
  const result = await pool.query(
    `INSERT INTO tickets (title, author_id)
     VALUES ($1, $2) RETURNING id, title, status, author_id, created_at`,
    [title, authorId]
  );
  return result.rows[0];
}
export async function getTicket(id) {
  const result = await pool.query(
    `SELECT id, title, status, author_id, created_at
     FROM tickets WHERE id = $1`, [id]
  );
  return result.rows[0] ?? null;
}

RETURNING возвращает фактически вставленную строку. Контроллеру не требуется угадывать назначенный идентификатор или делать второй запрос по заголовку. Заголовок не уникален: несколько пользователей могут сообщить об одном оборудовании. Первичный ключ различает записи, а повторяющийся текст выражает содержание. Операторы вставки и возвращаемые данные описаны в документации INSERT PostgreSQL.

Чтение отсутствующей строки превращается в null. Это договор функций хранилища, а не формат pg: драйвер по-прежнему возвращает пустой массив строк. Удобный договор позволяет контроллеру одинаково работать с реализацией в памяти и SQL. Если позже мы изменим способ хранения, HTTP-слой не должен знать, что раньше существовал result.rows. Такая граница уменьшает число связанных изменений.

Изменение состояния выполняется отдельной командой. В полном файле tickets.js также есть список и удаление, но здесь рассмотрим механизм обновления на одной функции. Она ещё не записывает историю: согласованное изменение двух таблиц появится в уроке о транзакции.

export async function setStatus(id, status) {
  const result = await pool.query(
    `UPDATE tickets SET status = $2 WHERE id = $1
     RETURNING id, title, status, author_id, created_at`,
    [id, status]
  );
  return result.rows[0] ?? null;
}

Одна команда UPDATE находит строку по ключу и возвращает новое состояние. Нельзя сначала читать строку, затем изменять JavaScript-объект и считать это изменением базы. Объект результата является копией данных в процессе, а не живой ссылкой на таблицу. Эта особенность отличается от изменения массива в начальном примере и требует явного SQL для каждой операции записи.

Ответ создаётся после сохранения

В ticket-controller.js обработчик создания ожидает завершения вставки. Только затем возвращает 201 Created. Если база отвергла операцию, клиент не должен получить успешный ответ. Возвращаемый адрес в Location указывает на ресурс, созданный в этом же API.

import { createTicket } from './tickets.js';
export async function create(req, res) {
  const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
  if (!title || Array.from(title).length > 120) {
    return res.status(400).json({ error: 'invalid_title' });
  }
  const ticket = await createTicket(title);
  res.location(`/api/tickets/${ticket.id}`).status(201).json(ticket);
}

Ожидаемый ответ содержит идентификатор, нормализованный заголовок и status: "open". Значения времени и номера зависят от базы. После следующего GET по адресу из Location должна прийти та же сохранённая заявка. После перезапуска процесса она также должна быть доступна, если приложение подключилось к прежней базе и схема сохранилась. Это критерий постоянства данных; удачный ответ одного запроса сам по себе его не доказывает.

await не останавливает все запросы Node.js. Он приостанавливает продолжение конкретной асинхронной функции до завершения обещания. Другие запросы могут работать, пока PostgreSQL выполняет команду. Но это не делает SQL мгновенным: занятое соединение остаётся ресурсом, а число ожидающих запросов может расти. Контроллер должен ограничивать работу и корректно обрабатывать недоступность базы.

Удаление и границы учебного CRUD

CRUD объединяет создание, чтение, изменение и удаление. Для DeskQueue удаление не является обычным завершением заявки: состояние closed сохраняет запись и её историю. В промежуточном состоянии stages/12 функция удаления показывает SQL-механизм для пустой учебной базы. В итоговом API физический маршрут удаления убран, поскольку ссылки событий и вложений требуют осмысленной политики хранения.

Внешний ключ запрещает удалить заявку, если на неё ссылается строка истории. Это не случайная помеха, а сигнал, что требуется решение о связанных данных. ON DELETE CASCADE мог бы автоматически удалить историю, но такой выбор противоречит нашей задаче сохранять изменения. Поэтому не исправляйте любую ошибку внешнего ключа добавлением каскада. Сначала определите, что означает операция для предметной области.

Также нельзя считать любую ошибку PostgreSQL неверным пользовательским вводом. Нарушение ограничения может быть связано с запросом, а потеря соединения — с инфраструктурой. В текущем состоянии Express 5 передаёт отклонённое обещание обработчику ошибок. В следующей главе придадим ошибкам устойчивую форму и отделим известные ошибки клиента от внутреннего сбоя. Это позволит поддерживать договор API, не раскрывая служебные детали.

Сравните два пути: сначала вы изменяли массив в одном процессе, теперь отправляете параметризованную команду общему хранилищу. HTTP-адреса почти не изменились, но место истины стало другим. Полный результат урока — CRUD через PostgreSQL с ожидаемыми статусами ответа. Приложение ещё не защищено авторизацией и не обеспечивает атомарную историю; именно эти ограничения задают следующие учебные шаги.

Функция изменения возвращает именно новое состояние, которое сохранила база. Это важно, если позднее появятся значения по умолчанию или другие правила записи. Клиент должен показывать подтверждённый результат, а не считать, что отправленная строка стала всей истиной. В нашей схеме поля простые, но выбранный способ уже поддерживает такую границу. Аналогично создание возвращает назначенное время базы, а не время часов браузера.

Не передавайте весь req.body в автоматически построенный UPDATE. Помимо заголовка пользователь способен добавить author_id, id и другие неожиданные поля. Даже параметризованный SQL не защитит от логически разрешённого изменения неправильного столбца. Параметры решают смешение данных и команды, а список изменяемых свойств решает полномочия операции. В DeskQueue эта ступень меняет только состояние, а создание принимает только заголовок.

Для чтения после записи сохраняйте идентификатор ответа. Поиск только по заголовку способен вернуть другое обращение с таким же текстом. Это особенно заметно после появления нескольких пользователей. Постоянное хранение расширяет время жизни данных, поэтому случайные предположения маленького массива нужно заменить явными ключами и договором результата.

Состояния open, working и closed намеренно остаются одинаковыми в JSON, SQL и HTTP. Если перевод интерфейса покажет «В работе», сервер всё равно хранит техническое working. Подпись для человека не должна попадать в столбец случайно. Это особенно полезно при нескольких языках интерфейса: база сохраняет устойчивый словарь, а представление выбирает локальную строку. Ошибка неизвестного состояния обнаруживается до успешного подтверждения изменения.

Оглавление курса

Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.