Pular para o conteúdo principal

Aula 9: Consultas, transações e seed com Prisma

O modelo da aula 8 está completo, mas vazio, e o service ainda não sabe tirar proveito de nenhuma relação. Esta aula fecha o ciclo de persistência do Módulo 2: povoar o banco de forma reproduzível, consultar as relações sem cair no erro de desempenho mais comum de qualquer ORM, reescrever o service por completo e usar transações nas duas operações que só fazem sentido inteiras — emprestar e devolver um exemplar.

O que vem depoisOnde
Autenticação, guards e proteção de rotasAula 10 — Autenticação e autorização
Consumo desta API pelo cliente webMódulo 3 — Next.js
Consumo da mesma API pelo cliente mobileMódulo 4 — Flutter

Objetivos​

Ao final desta aula, você deve ser capaz de:

  • Escrever consultas com filtro, ordenação, paginação, include e select, e reconhecer o problema N+1.
  • Reescrever um service completo sobre um modelo relacional, incluindo relações obrigatórias e opcionais na mesma consulta.
  • Traduzir o conjunto completo de erros do banco (P2002, P2025, P2003, P2000, P1001) em códigos de status HTTP.
  • Usar transações para operações que alteram mais de uma tabela, e distinguir o que uma transação garante do que ela não garante.
  • Popular o banco com um script de seed reproduzível, incluindo dados que exercitam um relacionamento opcional vazio.

Ambiente sugerido​

Mesmo ambiente das aulas 7 e 8 — nenhuma dependência nova entra nesta aula.


Parte 1 — Consultando​

Filtro, ordenação e paginação​

const livros = await this.prisma.livro.findMany({
where: {
ano: { gte: 1900 },
titulo: { contains: 'sertão', mode: 'insensitive' },
},
orderBy: { titulo: 'asc' },
skip: (pagina - 1) * tamanho,
take: tamanho,
});

mode: 'insensitive' resolve, no banco, o mesmo problema que na aula 6 era resolvido com .toLowerCase() em JavaScript — e com uma diferença importante: agora o filtro roda antes da paginação, sobre a tabela inteira. Filtrar em memória depois de paginar dá resultados errados, e é um erro comum.

Trazendo relacionamentos: include e select​

// include: o registro completo + as relações pedidas
const comAutor = await this.prisma.livro.findUnique({
where: { id },
include: { autor: true, editora: true, categorias: true },
});

// select: exatamente os campos listados, e nada mais
const resumo = await this.prisma.livro.findMany({
select: {
id: true,
titulo: true,
autor: { select: { nome: true } },
_count: { select: { exemplares: true } },
},
});
includeselect
Traz os campos escalares do modeloSim, todosSó os listados
Serve para relaçõesSimSim
Podem ser usados juntos no mesmo nívelNãoNão
Quando preferirVocê quer o registro inteiro mais as relaçõesVocê quer economizar banda ou esconder campos

select é a resposta direta ao overfetching discutido na aula 4: a tela do aplicativo mobile que mostra só título e autor não precisa carregar o ISBN, as datas e a observação interna de cada livro.

Relação opcional, include e null

include: { editora: true } num livro sem editoraId devolve editora: null — não undefined, não erro, não omite o campo. É o mesmo contrato de "existe, mas pode ser vazio" que o schema da aula 8 desenhou; o cliente que consumir esse JSON precisa tratar editora como possivelmente nulo, do mesmo jeito que trata categorias como possivelmente uma lista vazia.

O problema N+1​

Este é o erro de desempenho mais comum com ORM, e ele não dá nenhum sinal em desenvolvimento:

// Errado: 1 consulta para a lista + 1 por livro = N+1 consultas
const livros = await this.prisma.livro.findMany();
for (const livro of livros) {
livro.autor = await this.prisma.autor.findUnique({ where: { id: livro.autorId } });
}

// Certo: 1 consulta
const livros = await this.prisma.livro.findMany({ include: { autor: true } });

Com 3 livros de seed, a diferença é imperceptível. Com 500, são 501 idas ao banco para montar uma tela. A regra prática: se você escreveu uma consulta dentro de um laço, quase certamente ela deveria ser um include.


Parte 2 — O service, completo​

Este é o LivrosService final do Módulo 2 — mesma assinatura pública desde a aula 6, agora com as relações obrigatórias e a opcional na mesma consulta:

src/livros/livros.service.ts
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CriarLivroDto } from './dto/criar-livro.dto';
import { AtualizarLivroDto } from './dto/atualizar-livro.dto';
import { ConsultarLivrosDto } from './dto/consultar-livros.dto';

@Injectable()
export class LivrosService {
constructor(private readonly prisma: PrismaService) {}

async listar(consulta: ConsultarLivrosDto) {
const { autor, pagina, tamanho } = consulta;

const where: Prisma.LivroWhereInput = autor
? { autor: { nome: { contains: autor, mode: 'insensitive' } } }
: {};

// Uma transação de leitura: os dois resultados enxergam o mesmo estado.
const [dados, total] = await this.prisma.$transaction([
this.prisma.livro.findMany({
where,
include: { autor: true, editora: true },
orderBy: { titulo: 'asc' },
skip: (pagina - 1) * tamanho,
take: tamanho,
}),
this.prisma.livro.count({ where }),
]);

return {
dados,
pagina,
tamanho,
total,
totalDePaginas: Math.ceil(total / tamanho) || 1,
};
}

async buscarPorId(id: number) {
const livro = await this.prisma.livro.findUnique({
where: { id },
include: { autor: true, editora: true, categorias: true },
});

if (!livro) {
throw new NotFoundException(`Livro ${id} não encontrado`);
}
return livro;
}

async resumo() {
return this.prisma.livro.findMany({
select: {
id: true,
titulo: true,
autor: { select: { nome: true } },
_count: { select: { exemplares: true } },
},
orderBy: { titulo: 'asc' },
});
}

async criar(dto: CriarLivroDto) {
try {
return await this.prisma.livro.create({
data: dto,
include: { autor: true, editora: true },
});
} catch (erro) {
this.traduzirErro(erro, dto.isbn);
}
}

async atualizar(id: number, dto: AtualizarLivroDto) {
try {
return await this.prisma.livro.update({
where: { id },
data: dto,
include: { autor: true, editora: true },
});
} catch (erro) {
this.traduzirErro(erro, dto.isbn, id);
}
}

async remover(id: number): Promise<void> {
try {
await this.prisma.livro.delete({ where: { id } });
} catch (erro) {
// O mesmo P2003 significa coisas diferentes conforme a operação: ao
// criar, é o autor (ou a editora) que não existe; ao remover, são os
// empréstimos que ainda apontam para os exemplares deste livro. Os
// exemplares somem em cascata, mas Emprestimo usa o padrão (Restrict)
// e barra a exclusão.
if (
erro instanceof Prisma.PrismaClientKnownRequestError &&
erro.code === 'P2003'
) {
throw new ConflictException(
`Livro ${id} não pode ser removido: há empréstimos vinculados aos seus exemplares`,
);
}
this.traduzirErro(erro, undefined, id);
}
}

private traduzirErro(erro: unknown, isbn?: string, id?: number): never {
if (erro instanceof Prisma.PrismaClientKnownRequestError) {
switch (erro.code) {
case 'P2002':
throw new ConflictException(`ISBN ${isbn ?? ''} já cadastrado`.trim());
case 'P2025':
throw new NotFoundException(`Livro ${id ?? ''} não encontrado`.trim());
case 'P2003':
throw new ConflictException('Autor ou editora informados não existem');
}
}
throw erro;
}
}

Compare com a versão da aula 7 e repare no que mudou: include aparece em toda consulta que devolve um livro, agora trazendo autor e editora — a segunda pode vir null, e não é um caso de erro; resumo é novo, e usa select com _count para devolver só o que uma listagem enxuta precisa; traduzirErro ganhou o caso P2003 para criação (chave estrangeira inválida), e remover ganhou um caso especial para o mesmo código, porque o significado de "chave estrangeira violada" depende de qual operação o produziu.

E no que não mudou, mais uma vez: as rotas, os DTOs e os códigos de status que os clientes web e mobile já esperam.


Parte 3 — Erros do banco, o mapeamento completo​

A aula 7 tratou P2002 e P2025, os únicos que existiam num schema sem relação. Com chaves estrangeiras em campo, a tabela completa:

CódigoSignificadoStatusMensagem ao cliente
P2002Violação de restrição única409Qual valor já existe
P2025Registro não encontrado para a operação404Qual recurso não existe
P2003Violação de chave estrangeira409 ou 400Qual vínculo é inválido
P2000Valor longo demais para a coluna400Qual campo excedeu
P1001Não foi possível alcançar o banco503Mensagem genérica
Um código, mais de um significado

P2003 não identifica qual vínculo falhou — só que algum falhou. Em LivrosService.criar, ele pode vir de autorId ou de editoraId inválidos. Em LivrosService.remover, ele vem de Emprestimo barrando a exclusão em cascata dos exemplares. O código do banco é o mesmo; a mensagem correta depende de qual operação o gerou, e só quem escreve o catch sabe disso.

Nunca repasse a mensagem do Prisma ao cliente

A mensagem original do P2002 inclui o nome da tabela e da coluna (Unique constraint failed on the fields: (isbn)). Isso descreve a estrutura interna do banco, que não faz parte do contrato e não deveria ser conhecida por quem consome a API. Traduza para o vocabulário do domínio — foi o que a Parte 2 fez, e é a mesma regra do filtro de exceções da aula 6.


Parte 4 — Transações​

Algumas operações só fazem sentido inteiras. Emprestar um exemplar é uma delas: grava o empréstimo e muda a situação do exemplar. Se a segunda falhar depois da primeira, o acervo fica com um exemplar emprestado que consta como disponível.

src/emprestimos/emprestimos.service.ts
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

const PRAZO_EM_DIAS = 14;

@Injectable()
export class EmprestimosService {
constructor(private readonly prisma: PrismaService) {}

async emprestar(exemplarId: number, leitorId: number) {
return this.prisma.$transaction(async (tx) => {
const exemplar = await tx.exemplar.findUnique({ where: { id: exemplarId } });

if (!exemplar) {
throw new NotFoundException(`Exemplar ${exemplarId} não encontrado`);
}
if (exemplar.situacao !== 'DISPONIVEL') {
throw new BadRequestException(`Exemplar ${exemplarId} não está disponível`);
}

const previstoPara = new Date();
previstoPara.setDate(previstoPara.getDate() + PRAZO_EM_DIAS);

const emprestimo = await tx.emprestimo.create({
data: { exemplarId, leitorId, previstoPara },
include: { exemplar: { include: { livro: true } }, leitor: true },
});

await tx.exemplar.update({
where: { id: exemplarId },
data: { situacao: 'EMPRESTADO' },
});

return emprestimo;
});
}
}

Duas formas de transação, com usos diferentes:

FormaSintaxeQuando usar
Em lote$transaction([consultaA, consultaB])As operações são independentes e conhecidas de antemão
Interativa$transaction(async (tx) => ...)Uma operação depende do resultado da anterior
Dentro da transação, use tx, não this.prisma

Uma consulta feita com this.prisma dentro do bloco roda fora da transação — ela não enxerga as alterações pendentes e não é desfeita no rollback. O erro é silencioso e só aparece sob concorrência. Se a variável se chama tx, use tx.

Transações interativas seguram uma conexão aberta e têm tempo limite. Nunca coloque dentro delas chamadas a serviços externos — envio de e-mail, requisição HTTP, upload. Faça isso depois do commit.

A devolução é a mesma transação, na direção inversa​

Devolver um exemplar também grava em duas tabelas: marca a devolução no empréstimo e libera o exemplar. E acrescenta duas verificações: um empréstimo já devolvido não pode ser devolvido de novo, e só um exemplar que está de fato EMPRESTADO pode voltar à circulação.

src/emprestimos/emprestimos.service.ts
async devolver(id: number) {
return this.prisma.$transaction(async (tx) => {
// A situação do exemplar faz parte da decisão, então vem junto.
const emprestimo = await tx.emprestimo.findUnique({
where: { id },
include: { exemplar: true },
});

if (!emprestimo) {
throw new NotFoundException(`Empréstimo ${id} não encontrado`);
}
if (emprestimo.devolvidoEm) {
throw new BadRequestException(`Empréstimo ${id} já foi devolvido`);
}
if (emprestimo.exemplar.situacao !== 'EMPRESTADO') {
throw new BadRequestException(
`Exemplar ${emprestimo.exemplarId} não está emprestado`,
);
}

const devolvido = await tx.emprestimo.update({
where: { id },
data: { devolvidoEm: new Date() },
});

await tx.exemplar.update({
where: { id: emprestimo.exemplarId },
data: { situacao: 'DISPONIVEL' },
});

return devolvido;
});
}
A invariante que as duas checagens guardam

emprestar só deixa sair um exemplar DISPONIVEL; devolver só deixa voltar um EMPRESTADO. As duas guardam a mesma invariante — empréstimo em aberto se e somente se exemplar EMPRESTADO —, cada uma de um lado. Encontrar um empréstimo em aberto cujo exemplar está EM_REPARO, BAIXADO ou DISPONIVEL significa que ela já foi rompida em algum outro ponto do sistema; gravar DISPONIVEL por cima faria a API parecer funcionar e apagaria a evidência.

Repare que essa é uma decisão de domínio, não uma imposição do framework. No seu domínio, a pergunta equivalente é qual par de estados não pode existir ao mesmo tempo — e quem impede que ele apareça.

Transação garante atomicidade, não exclusão mútua

A transação garante que as duas escritas aconteçam juntas ou nenhuma — e é só isso que ela garante. Ela não impede que duas devoluções concorrentes do mesmo empréstimo passem as duas pela checagem: no nível de isolamento padrão do PostgreSQL, ambas podem ler devolvidoEm: null antes de qualquer uma escrever, e a segunda sobrescreve a primeira sem erro.

Manter a leitura dentro do tx continua sendo o padrão correto — decisão e escrita pertencem à mesma unidade —, mas fechar essa janela por completo exige uma escrita condicional, que só grava se o empréstimo ainda estiver em aberto. Vale saber que a distinção existe: confundir atomicidade com exclusão mútua é a origem de uma classe inteira de defeitos que só aparecem sob concorrência.

Exposta no controller:

src/emprestimos/emprestimos.controller.ts
@Patch(':id/devolucao')
@ApiOperation({ summary: 'Registra a devolução de um empréstimo' })
@ApiBadRequestResponse({
description: 'Empréstimo já devolvido, ou exemplar não está emprestado',
})
@ApiNotFoundResponse({ description: 'Empréstimo não encontrado' })
devolver(@Param('id', ParseIntPipe) id: number) {
return this.emprestimosService.devolver(id);
}

Parte 5 — Seed​

Um banco vazio impede de testar listagem, filtro, paginação e a relação opcional. O script de seed resolve isso de forma reproduzível — qualquer pessoa que clonar o projeto obtém o mesmo conjunto de dados.

prisma/seed.ts
import { PrismaPg } from '@prisma/adapter-pg';
import 'dotenv/config';
import { PrismaClient } from '../src/generated/prisma/client';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });

async function main() {
// Ordem importa: apagar primeiro quem depende dos outros.
await prisma.emprestimo.deleteMany();
await prisma.exemplar.deleteMany();
await prisma.fichaCatalografica.deleteMany();
await prisma.livro.deleteMany();
await prisma.categoria.deleteMany();
await prisma.editora.deleteMany();
await prisma.autor.deleteMany();
await prisma.leitor.deleteMany();

// Auto-relacionamento: 'Literatura' é raiz (paiId nulo) e as duas
// seguintes apontam para ela. É a hierarquia modelada na aula 8.
const literatura = await prisma.categoria.create({ data: { nome: 'Literatura' } });
const romance = await prisma.categoria.create({
data: { nome: 'Romance', paiId: literatura.id },
});
const classico = await prisma.categoria.create({
data: { nome: 'Clássico brasileiro', paiId: literatura.id },
});

const record = await prisma.editora.create({ data: { nome: 'Record' } });

// Escrita aninhada: autor, livro, categorias e exemplares num comando só.
await prisma.autor.create({
data: {
nome: 'Machado de Assis',
nacionalidade: 'Brasileira',
livros: {
create: [
{
titulo: 'Dom Casmurro',
isbn: '9788525406958',
ano: 1899,
// Sem editoraId: exercita o relacionamento opcional vazio.
categorias: { connect: [{ id: romance.id }, { id: classico.id }] },
// Escrita aninhada no 1:1 — o Prisma resolve o livroId sozinho.
ficha: {
create: { cdd: '869.3', paginas: 256, sinopse: 'Bentinho e a suspeita que o consome.' },
},
exemplares: {
create: [{ tombo: 'DC-001' }, { tombo: 'DC-002' }, { tombo: 'DC-003' }],
},
},
{
titulo: 'Memórias Póstumas de Brás Cubas',
isbn: '9788535914849',
ano: 1881,
// Sem ficha: exercita o 1:1 vazio — o include devolve null.
categorias: { connect: [{ id: classico.id }] },
exemplares: { create: [{ tombo: 'MP-001' }] },
},
],
},
},
});

await prisma.autor.create({
data: {
nome: 'Clarice Lispector',
nacionalidade: 'Brasileira',
livros: {
create: [
{
titulo: 'A Hora da Estrela',
isbn: '9788520925829',
ano: 1977,
editoraId: record.id, // Este livro tem editora — contraste com os dois acima.
categorias: { connect: [{ id: romance.id }] },
ficha: {
create: { cdd: '869.3', paginas: 96, sinopse: 'A vida mínima de Macabéa, por quem a cria.' },
},
exemplares: { create: [{ tombo: 'HE-001' }, { tombo: 'HE-002' }] },
},
],
},
},
});

await prisma.leitor.createMany({
data: [
{ nome: 'Ana Souza', email: 'ana@exemplo.com' },
{ nome: 'Bruno Lima', email: 'bruno@exemplo.com' },
],
});

console.log('Seed concluído.');
}

main()
.catch((erro) => {
console.error(erro);
process.exit(1);
})
.finally(() => prisma.$disconnect());

O trecho mais instrutivo é a escrita aninhada: create dentro de create grava autor, livros, vínculos de categoria e exemplares em uma única operação, e o Prisma resolve as chaves estrangeiras sozinho. connect liga a um registro que já existe; create cria um novo. E repare nos contrastes deliberados: dois dos três livros não recebem editoraId e um deles fica sem ficha — o seed também precisa exercitar os caminhos opcionais, não só os obrigatórios. As categorias, por sua vez, são criadas em hierarquia: paiId nulo na raiz e preenchido nas duas filhas, exatamente como o auto-relacionamento da aula 8 prevê.

Seed não roda mais sozinho

Até a versão 6, migrate dev e migrate reset executavam o seed automaticamente. Na versão 7 é preciso chamar npx prisma db seed explicitamente. O comando que ele executa é declarado em prisma.config.ts, desde a aula 7.


Erros comuns​

ErroSintomaCorreção
Consulta dentro de laçoLento sob volume, imperceptível em desenvolvimentoTrocar por include
Filtrar em memória depois de paginarPágina com menos itens que o esperadoFiltrar no where
Esquecer include da relação opcionalCliente recebe o campo ausente em vez de nullSempre incluir editora: true explicitamente
Usar this.prisma dentro de $transactionA operação não é desfeita no rollbackUsar o tx recebido
Supor que a transação serializa requisiçõesDuas devoluções concorrentes passam as duas pela checagemTransação garante atomicidade; exclusão exige escrita condicional
Gravar a situação nova sem conferir a anteriorUm exemplar EM_REPARO volta a DISPONIVEL na devoluçãoGuardar a invariante nos dois lados: só sai DISPONIVEL, só volta EMPRESTADO
Repassar a mensagem do PrismaVaza nome de tabela e colunaTraduzir em traduzirErro
Listagem sem takeUma requisição carrega a tabela inteiraPaginar sempre
deleteMany do seed na ordem erradaViolação de chave estrangeiraApagar primeiro quem depende

Laboratório 9 — Dados, consultas e transações de verdade​

Continuação direta do laboratório 8. Ao final, o acervo está populado, o service consome as relações por completo e o empréstimo é uma operação transacional testada nos seus casos de falha.

Crie prisma/seed.ts conforme a Parte 5 e execute:

npx prisma db seed

Passo 2 — Confirmar com uma listagem​

curl -s "http://localhost:3000/livros?tamanho=10" | head -40

Cada livro deve vir com o objeto autor aninhado. Localize, na resposta, o livro que não tem editora — o campo editora deve aparecer como null, não ausente.

Passo 3 — Inspecionar com o Prisma Studio​

npx prisma studio

Percorra as sete tabelas e confirme visualmente:

  1. os três livros e seus autorId — e que só um tem editoraId preenchido;
  2. a tabela de junção que o Prisma criou sozinho para Livro–Categoria;
  3. os exemplares, todos com situação DISPONIVEL.

Passo 4 — Reescrever o LivrosService por completo​

Substitua LivrosService pela versão da Parte 2 — agora com include em toda consulta que devolve um livro e traduzirErro cobrindo P2003.

Passo 5 — Expor o resumo​

Acrescente ao controller:

@Get('resumo')
@ApiOperation({ summary: 'Lista o acervo resumido, com contagem de exemplares' })
resumo() {
return this.livrosService.resumo();
}

Exponha em GET /livros/resumo — antes de @Get(':id') no controller, pelo motivo visto na aula 5. Compare o tamanho da resposta com a de GET /livros: essa diferença é o overfetching que a aula 4 descreveu, agora medida.

Passo 6 — Implementar o empréstimo com transação​

nest g module emprestimos
nest g service emprestimos --no-spec
nest g controller emprestimos --no-spec

Implemente emprestar e devolver conforme a Parte 4, e exponha:

@Post()
@HttpCode(HttpStatus.CREATED)
emprestar(@Body() dto: CriarEmprestimoDto) {
return this.emprestimosService.emprestar(dto.exemplarId, dto.leitorId);
}

Crie o DTO com @IsInt() e @Min(1) nos dois campos. Depois teste os cenários:

# 1. empréstimo válido — 201, e a situação do exemplar muda
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":1,"leitorId":1}'

# 2. o mesmo exemplar de novo — 400, porque não está mais disponível
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":1,"leitorId":2}'

# 3. exemplar inexistente — 404
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":9999,"leitorId":1}'

Confirme no Prisma Studio que o exemplar 1 está EMPRESTADO e que existe um registro em Emprestimo.

Agora prove que a transação funciona: inverta temporariamente a ordem dentro do bloco, colocando o update da situação antes do create do empréstimo, e force um erro no create usando um leitorId inexistente. A operação deve falhar com erro de chave estrangeira e o exemplar deve continuar DISPONIVEL — se ele tiver mudado, a transação não está envolvendo as duas operações. Desfaça a inversão em seguida.

Implemente também devolver, e exponha PATCH /emprestimos/:id/devolucao. Teste:

# 4. devolução válida — o exemplar volta a DISPONIVEL
curl -i -X PATCH http://localhost:3000/emprestimos/1/devolucao

# 5. devolver o mesmo empréstimo de novo — 400
curl -i -X PATCH http://localhost:3000/emprestimos/1/devolucao

# 6. remover o livro cujo exemplar tem (ou teve) empréstimo — 409
curl -i -X DELETE http://localhost:3000/livros/1

Para ver a segunda checagem agir, quebre a invariante de propósito: empreste outro exemplar, mude a situação dele para EM_REPARO no Prisma Studio e só então tente devolver. A resposta deve ser 400 com a mensagem sobre o exemplar — e não um 200 que devolveria à circulação uma cópia que está na oficina. Desfaça a alteração em seguida.

O cenário 6 é o efeito do onDelete implícito discutido na aula 8: mesmo com o empréstimo já devolvido, o registro em Emprestimo continua existindo e apontando para o exemplar — e Emprestimo barra a exclusão em cascata.

No seu projeto

Identifique no seu domínio uma operação que altere duas tabelas e não faça sentido pela metade: dar baixa em estoque ao confirmar um pedido, ocupar uma vaga ao efetivar uma matrícula, reservar um assento ao emitir um bilhete. Implemente-a com $transaction interativa. Se você não encontrar nenhuma, o seu modelo provavelmente ainda está raso demais — reveja os relacionamentos.

Passo 7 — Verificar a documentação​

Abra http://localhost:3000/docs e confirme:

  • as operações de livros continuam documentadas, agora com autorId e editoraId opcional;
  • GET /livros/resumo aparece documentado, antes de GET /livros/:id;
  • as operações de empréstimo e devolução aparecem com o corpo e a rota corretos.

Atividades propostas​

  1. Escreva uma consulta que liste apenas os livros sem editora cadastrada (editoraId: null) e exponha-a como um novo endpoint ou como um filtro do GET /livros existente.
  2. Pagine e filtre GET /emprestimos, reaproveitando o padrão de ConsultarLivrosDto da aula 6 — pense em quais filtros fazem sentido para empréstimo (por leitor? por situação de devolução?).
  3. Implemente um GET /livros/:id/emprestimos, que liste o histórico de empréstimos de todos os exemplares de um livro — isso exige atravessar duas relações (Livro → Exemplar → Emprestimo) num único include aninhado.

Critérios de conclusão​

  • npx prisma db seed popula o banco de forma reproduzível, com pelo menos um livro sem editora;
  • a listagem traz o autor aninhado com uma única consulta, e a editora aparece como null quando ausente;
  • GET /livros/resumo usa select e _count;
  • emprestar um exemplar indisponível devolve 400;
  • a transação de emprestar foi verificada: em caso de erro, nada é gravado pela metade;
  • devolver um empréstimo grava devolvidoEm e devolve o exemplar a DISPONIVEL;
  • devolver o mesmo empréstimo uma segunda vez devolve 400;
  • devolver um empréstimo cujo exemplar não está EMPRESTADO devolve 400, em vez de sobrescrever a situação;
  • remover um livro com empréstimo vinculado a algum exemplar devolve 409, sem citar nome de tabela ou coluna na mensagem;
  • o seu projeto tem os mesmos elementos: relacionamentos, seed e pelo menos uma operação transacional.

Fechamento​

Com esta aula o serviço fica completo em suas três camadas: contrato HTTP, regra de negócio e persistência — modelada, populada e consultada por completo. O resultado mais importante não é o banco funcionando, e sim algo que atravessa as três aulas do bloco de persistência: o armazenamento foi inteiramente substituído, depois modelado, depois povoado e consultado, sem que uma linha do controller ou dos DTOs originais da aula 6 mudasse por capricho — só quando o próprio contrato precisou evoluir, e de forma deliberada.

Esse desacoplamento é o que torna viável o resto do semestre. Os clientes web (Módulo 3) e mobile (Módulo 4) serão escritos contra o contrato publicado aqui, e vão assumir que ele é estável. Na aula 10, essa mesma base ganha autenticação e autorização — e o lugar onde elas entram, o guard, você já conhece desde a aula 5.


Exercícios (Checkpoints)​

  1. Identifique o problema no trecho a seguir, explique por que ele não aparece em desenvolvimento e reescreva-o:

    const livros = await prisma.livro.findMany();
    for (const livro of livros) {
    livro.autor = await prisma.autor.findUnique({ where: { id: livro.autorId } });
    }
  2. Compare include e select e decida qual usar em cada caso, justificando: (a) tela do aplicativo que lista título e nome do autor; (b) tela de detalhe do livro com categorias, exemplares e editora; (c) exportação completa do acervo para um relatório.

  3. Explique por que include: { editora: true } num livro sem editora devolve editora: null em vez de omitir o campo, e por que essa diferença importa para quem escreve o cliente.

  4. Mapeie cada código de erro do Prisma (P2002, P2025, P2003, P2000, P1001) para um status HTTP e escreva a mensagem que você devolveria ao cliente — sem revelar nome de tabela ou coluna. Para P2003, explique por que o mesmo código produz mensagens diferentes conforme a operação seja criar ou remover.

  5. Analise o que aconteceria se, dentro de $transaction(async (tx) => ...), uma das consultas usasse this.prisma em vez de tx. Descreva o comportamento observável sob duas requisições concorrentes.

  6. Liste as duas escritas que devolver precisa fazer juntas e explique o que a transação garante — e o que ela não garante — quando duas devoluções do mesmo empréstimo chegam ao mesmo tempo.

  7. Projete, para o seu estudo de caso, uma consulta que precise de include em duas relações encadeadas (como Livro → Exemplar → Emprestimo) e identifique o risco de N+1 se ela fosse escrita sem include.


Referências​

Principais​

Aprofundamento​