NestJS: репозиторий и параметризованные запросы
База имеет подготовленную схему, а Database предоставляет pool. В repository соединим это хранение с внешней моделью Ticket. SELECT будет принимать данные отдельно от SQL-текста, а функция ticket преобразует updated_at в updatedAt. Результат главы — чтение списка и карточки с согласованным JSON, понятным 404 и буквальным поиском. Controller продолжит вызывать предметный service, не получая информацию о конкретных колонках PostgreSQL.
Параметризованный запрос отделяет структуру SQL от значений q, status и id. Нельзя вставлять пользовательскую строку в текст через template literal и считать TypeScript-тип защитой. Например, id приходит из URL, а поиск может содержать кавычки. Placeholders $1/$2 передают эти данные драйверу как значения. Такое разделение относится к SQL injection, но не автоматически к смыслу LIKE или эффективности поиска. Мы отдельно выбрали strpos для буквального фрагмента и назвали границы Unicode/производительности.
Строка базы и wire-модель
Внутренний Row содержит updated_at как Date. Внешний Ticket требует updatedAt как ISO string. Функция ticket явно строит пять полей и не отдаёт весь result.rows напрямую. Это помогает сохранить API, если SQL позже получит дополнительные служебные колонки. Например, оконное total используется только envelope списка и не должно появиться в каждой заявке. Явное отображение делает такую границу видимой в коде, а не случайным побочным эффектом сериализации драйвера.
Ниже методы list и get из src/tickets.repository.ts; constructor получает db, Row и ticket объявлены выше в полном файле. Для list запрос использует параметры status и q, сортирует по id и ограничивает выдачу. Get выбирает только по id и превращает отсутствие rows[0] в NotFoundException. Фрагмент не включает patch: отдельная транзакционная операция появится следующей главой и использует другой способ получения client, хотя возвращает тот же Ticket.
async list(filter: ListTicketsDto) {
// Поиск буквального фрагмента; знак % не превращается в wildcard.
const result = await this.db.pool.query<Row & {total:string}>(
`SELECT id,title,status,version,updated_at,count(*) OVER() AS total
FROM tickets WHERE ($1::text IS NULL OR status=$1)
AND ($2::text='' OR strpos(lower(title),lower($2))>0) ORDER BY id LIMIT 200`,
[filter.status ?? null, filter.q ?? '']
);
return {items:result.rows.map(ticket),total:result.rows.length ? Number(result.rows[0].total) : 0};
}
async get(id: string): Promise<Ticket> {
const result = await this.db.pool.query<Row>('SELECT id,title,status,version,updated_at FROM tickets WHERE id=$1',[id]);
if (!result.rows[0]) throw new NotFoundException('Заявка не найдена');
return ticket(result.rows[0]);
}
Ожидаемый get rq-101 вернёт id, title, new, version1 и ISO-время из seed. Get rq-999 не возвращает undefined как успешный 200: он выбрасывает 404, который общий filter оформит envelope. Это важно для Angular readTicket: пустое тело не является корректной заявкой. Ответ об отсутствии должен описывать отказ операции, а не заставлять клиент угадывать, является ли undefined ещё одним вариантом предметной модели.
Список и total
Count(*) over() считает подходящие строки до limit200 и повторяется в SQL-result каждой выданной строки. Pg представляет этот count как текст, поэтому total преобразуется Number. Для пустой выборки нет строки с оконным значением, и repository возвращает 0. Таким способом один запрос получает items и число всех совпадений при одном statement. Отдельный COUNT и отдельный SELECT могли бы наблюдать разные состояния между операциями, если данные изменяются; лаборатория выбрала согласованный statement без второго чтения.
Первые 200 строк не являются всей базой при большом total. Поэтому клиент показывает найденное и показанное отдельно. Repository не реализует cursor, next или offset. Если понадобятся следующие страницы, модель envelope и URL-фильтра придётся расширить. Не пытайтесь скрыть ограничение, присваивая total равным items.length: это сделает ответ удобнее для старой формы, но нарушит реальный смысл найденных данных. Честный ограниченный контракт полезнее недокументированного усечения результата.
Status передаётся как null, когда фильтра нет. SQL-условие $1::text IS NULL OR status=$1 тогда пропускает все допустимые состояния. Q по умолчанию пустое, и второе условие также пропускает все titles. Непустые значения соединяются AND. Сравнение выполняется после DTO, поэтому repository не делает ещё одну случайную нормализацию неизвестного status в «Все». Транспорт уже проверил допустимую форму, а repository реализует её определённую семантику.
Пул для одиночного чтения
Pool.query удобен для отдельного SELECT: драйвер сам берёт и освобождает подключение. Но несколько pool.query не обещают один и тот же client. Для транзакции такой подход непригоден, даже если написать BEGIN и COMMIT как отдельные обращения. В следующей главе patch использует pool.connect и держит client до finally. Это различие относится к владению соединением, а не к тому, похожи ли строки SQL визуально на последовательный сценарий.
Get читает текущее доступное состояние без row lock. Его задача показать основу для возможного редактирования, а не навсегда зарезервировать запись за открытой вкладкой. Диспетчер может думать долго; удерживать транзакцию через время ввода формы было бы иной архитектурой с блокировками и ресурсами. Version позволяет закрыть чтение и позже проверить, сохранилась ли основа. Поэтому успешный GET сегодня не гарантирует успешный PATCH той же version спустя минуту.
Repository возвращает Promise, а thrown HttpException проходит в общую HTTP-цепочку. В большой системе слой данных мог бы бросать собственную доменную ошибку, которую service переводит в HTTP. Для компактной лаборатории NotFoundException и ConflictException применены непосредственно. Это осознанная связь с Nest, которую нужно учитывать при переносе repository в отдельную не-HTTP-библиотеку. Мы не называем его полностью framework-independent только потому, что controller не содержит SQL.
Рассмотрите запрос q с кавычкой или процентом. Он остаётся значением параметра и не изменяет SQL-структуру; процент ищется буквально. Ожидаемое отсутствие совпадений возвращает items[]/total0, а не 500. Недоступное подключение — иной случай и попадёт в фильтр неожиданных ошибок. Такие сценарии не запускались. Читатель может проверить их после создания базы, а до этого полезно проследить, какой слой отвечает за формат, значение фильтра и сам ресурс соединения.
Теперь чтение и внешний JSON определены полностью. Следующий шаг соединит row lock, сравнение version, UPDATE и INSERT истории в одну транзакцию. Текущая функция ticket останется общей для всех результатов, чтобы успешный PATCH не выдавал другую форму даты или дополнительные SQL-поля. Сквозная модель позволит клиенту принять новую основу без особого «послесохранительного» типа, который пришлось бы отдельно поддерживать во всех страницах.
Статус примера — not_run. Зависимости, приложение, SQL, HTTP и тесты не запускались. Описанные ответы и изменения интерфейса ожидаемые; чтение документации не является проверкой выполнением. Полная конечная версия находится в архиве исходников, а её ограничения и действия читателя — в README и CONTRACT.md.
Перейдите к оглавлению серии или общему маршруту интерактивной веб-разработки. Механизм этой главы сверяется с официальным первичным источником; конкретные версии и непроверенные части перечислены в договоре курса.