Pular para o conteúdo principal

Aula 7: Acesso a bases de dados com Prisma e PostgreSQL

Na aula 6 o CRUD ficou completo, validado e documentado — e todo o acervo desaparecia a cada reinício da aplicação. Esta aula troca o array em memória por um banco relacional real.

A troca é também um teste de arquitetura. Se as aulas 5 e 6 separaram corretamente transporte, regra e dados, então substituir a infraestrutura não deve exigir tocar em rotas, DTOs ou códigos de status. É exatamente isso que o laboratório vai verificar, antes de acrescentar qualquer funcionalidade nova.

O que vem depoisOnde
Autenticação, guards e proteção de rotasAula 8 — 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:

  • Explicar o que um ORM resolve, o que ele custa e quando SQL direto é preferível.
  • Modelar um domínio com relacionamentos 1:N, N:N implícito e junção explícita no schema do Prisma.
  • Gerar e aplicar migrations, e distinguir migrate dev de migrate deploy.
  • Integrar o Prisma ao NestJS por meio de um provider injetável.
  • Escrever consultas com filtro, ordenação, paginação, include e select, e reconhecer o problema N+1.
  • Usar transações para operações que alteram mais de uma tabela.
  • Traduzir erros do banco em códigos de status HTTP corretos.
  • Popular o banco com um script de seed reproduzível.

Ambiente sugerido

FerramentaVersãoObservação
PostgreSQL16 ou superior, em localhost:5432instalação local; a aula assume que o serviço já está no ar
Prisma ORM7.xprisma (CLI) e @prisma/client
Adaptador@prisma/adapter-pgobrigatório na versão 7
Node.js22 LTS ou superiorrequisito do Prisma 7 e do NestJS 11
A versão 7 do Prisma mudou bastante

Material sobre Prisma escrito para as versões 5 e 6 — que ainda é a maior parte do que se encontra na web — não funciona sem adaptação. As quatro mudanças que mais afetam este laboratório:

  1. O gerador passou a ser prisma-client (e não prisma-client-js), com output obrigatório: o cliente não é mais gerado dentro de node_modules, e a importação deixa de ser @prisma/client.
  2. Criar o PrismaClient exige um driver adapter — para PostgreSQL, @prisma/adapter-pg.
  3. O arquivo .env não é lido automaticamente; a configuração passou para prisma.config.ts.
  4. migrate dev não roda mais generate nem o seed automaticamente — os dois viraram comandos explícitos.

Ao consultar a documentação, confirme que a página se refere à versão 7.


Parte 1 — O que um ORM resolve

Sem ORM, buscar um livro é escrever a consulta, mapear o resultado e cuidar dos tipos na mão:

const resultado = await pool.query('SELECT id, titulo, ano FROM livro WHERE id = $1', [id]);
const linha = resultado.rows[0]; // tipo: any

O problema não é a consulta — é tudo em volta dela: linha não tem tipo, o nome das colunas só é verificado em tempo de execução, e um SELECT que esqueceu uma coluna só falha quando alguém acessa o campo faltante.

Um ORM (Object-Relational Mapper) faz a ponte entre tabelas e objetos. O Prisma faz isso de um jeito específico, que é o motivo de ele ter sido escolhido para a disciplina: a partir de um schema declarativo, ele gera um cliente TypeScript em que cada modelo, campo e relação existe como tipo.

const livro = await prisma.livro.findUnique({ where: { id } });
// ^? Livro | null — o editor conhece todos os campos
O que o ORM resolveO que ele custa
Tipagem de ponta a ponta entre banco e códigoUma camada a mais para aprender e depurar
Migrations versionadas junto com o códigoConsultas muito específicas ficam difíceis de expressar
Proteção contra injeção de SQL por parametrizaçãoO SQL gerado nem sempre é o que você escreveria
Portabilidade parcial entre bancosRisco real de escrever consultas ineficientes sem perceber
ORM não dispensa saber SQL

A consulta ineficiente que o ORM gera continua sendo uma consulta ineficiente — e quem vai diagnosticá-la é quem entende de índice, plano de execução e JOIN. O Prisma tem uma saída para os casos em que a abstração atrapalha: $queryRaw executa SQL parametrizado e devolve o resultado tipado. Usá-lo não é derrota; é escolher a ferramenta certa para o caso.


Parte 2 — Onde o banco entra na arquitetura

A camada de persistência entra abaixo do service, e o desenho abaixo é menos sobre o que cada camada faz do que sobre o que ela deliberadamente ignora.

Quatro camadas empilhadas — controller, service, PrismaService e PostgreSQL — com a indicação, para cada uma, do vocabulário que ela fala e do que ela não conhece

O critério de que a separação está correta é objetivo: trocar o armazenamento deve alterar apenas o service e a camada Prisma. Se o controller, os DTOs ou as rotas precisarem mudar, a infraestrutura vazou para o contrato — e os clientes web e mobile, que já foram escritos contra ele, quebram.


Parte 3 — Modelando o domínio

O domínio-guia dos laboratórios é uma biblioteca. Ele foi escolhido pequeno o bastante para caber em uma aula e completo o bastante para exigir os três tipos de relacionamento que o Prisma trata de formas diferentes.

Modelo de dados da biblioteca: Autor escreve Livro (1:N), Livro é classificado por Categoria (N:N), Livro tem Exemplar (1:N), Leitor toma Emprestimo (1:N) e Exemplar é emprestado (1:N), com Emprestimo funcionando como tabela de junção explícita

O schema completo:

prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}

datasource db {
provider = "postgresql"
}

model Autor {
id Int @id @default(autoincrement())
nome String @db.VarChar(120)
nacionalidade String? @db.VarChar(60)
livros Livro[]
criadoEm DateTime @default(now())
}

model Livro {
id Int @id @default(autoincrement())
titulo String @db.VarChar(200)
isbn String @unique @db.VarChar(20)
ano Int
autorId Int
autor Autor @relation(fields: [autorId], references: [id], onDelete: Restrict)
categorias Categoria[]
exemplares Exemplar[]
criadoEm DateTime @default(now())
atualizadoEm DateTime @updatedAt

@@index([autorId])
}

model Categoria {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(60)
livros Livro[]
}

enum SituacaoExemplar {
DISPONIVEL
EMPRESTADO
EM_REPARO
BAIXADO
}

model Exemplar {
id Int @id @default(autoincrement())
tombo String @unique @db.VarChar(20)
situacao SituacaoExemplar @default(DISPONIVEL)
livroId Int
livro Livro @relation(fields: [livroId], references: [id], onDelete: Cascade)
emprestimos Emprestimo[]

@@index([livroId])
}

model Leitor {
id Int @id @default(autoincrement())
nome String @db.VarChar(120)
email String @unique @db.VarChar(180)
ativo Boolean @default(true)
emprestimos Emprestimo[]
}

model Emprestimo {
id Int @id @default(autoincrement())
exemplarId Int
exemplar Exemplar @relation(fields: [exemplarId], references: [id])
leitorId Int
leitor Leitor @relation(fields: [leitorId], references: [id])
retiradoEm DateTime @default(now())
previstoPara DateTime
devolvidoEm DateTime?

@@index([leitorId])
@@index([exemplarId])
}

As decisões que esse schema carrega

1:N — a chave estrangeira mora no lado N. Livro guarda autorId; Autor tem a lista livros[], que não existe como coluna: é a relação vista do outro lado. A escolha de onDelete é de negócio, não técnica:

EstratégiaEfeito ao apagar o autorQuando usar
RestrictA operação falha se houver livrosO registro dependente tem valor próprio
CascadeOs livros são apagados juntoO dependente não faz sentido sozinho
SetNullautorId vira null (exige campo opcional)O vínculo é acessório

Aqui, apagar um autor não pode apagar a obra dele — daí Restrict. Já apagar um livro deve apagar seus exemplares, que não existem sem ele — daí Cascade.

N:N implícito — o Prisma cuida da tabela de junção. Livro.categorias e Categoria.livros bastam: o Prisma cria e mantém a tabela intermediária sozinho. Isso só funciona porque a relação não tem atributos próprios.

Junção explícita — quando a relação vira entidade. Assim que a ligação entre Exemplar e Leitor ganhou datas próprias, ela deixou de caber numa tabela automática e virou Emprestimo.

A regra que decide entre N:N implícito e entidade

Pergunte se você consegue imaginar um atributo da própria relação — data, quantidade, situação, nota, quem autorizou. Se sim, ela é uma entidade. Descobrir isso depois de gravar dados custa uma migration de estrutura e de conteúdo.

Índices. Todo campo usado como filtro frequente merece índice. @unique já cria um; @@index([autorId]) cobre a busca de livros por autor, que é uma consulta esperada. Sem índice, o PostgreSQL varre a tabela inteira — imperceptível com 20 registros de seed, fatal com 200 mil.


Parte 4 — Migrations

Uma migration é a diferença entre o estado atual do banco e o schema desejado, gravada como SQL versionado. O comando que a produz faz três coisas:

As três saídas de prisma migrate dev: o arquivo SQL versionado em prisma/migrations, a estrutura aplicada no banco local e o Prisma Client regenerado com os tipos novos
npx prisma migrate dev --name criar_acervo
npx prisma generate
Na versão 7, generate é um passo separado

Até a versão 6, migrate dev regenerava o cliente automaticamente. Isso mudou. Se você alterar o schema, rodar a migration e o editor continuar sem reconhecer o campo novo, quase sempre falta rodar prisma generate.

O SQL gerado fica em prisma/migrations/<timestamp>_criar_acervo/migration.sql. Ele entra no Git — é o histórico do banco, e é o que permite a outra pessoa reproduzir a mesma estrutura a partir de um banco vazio.

ComandoOnde usarO que faz
migrate devSó em desenvolvimentoCompara, gera a migration e aplica; pode propor recriar o banco
migrate deployAmbiente compartilhado e produçãoApenas aplica as pendentes; nunca recria
migrate resetDesenvolvimentoApaga tudo e reaplica do zero
db pushProtótipo descartávelSincroniza sem gerar migration — não use no projeto
migrate dev pode apagar seus dados

Se o Prisma detectar que o banco divergiu do histórico — porque alguém alterou uma tabela pelo psql, por exemplo — ele propõe recriar o banco. Em desenvolvimento isso é aceitável; num banco compartilhado com a turma, não. Leia o que o comando pergunta antes de confirmar.


Parte 5 — O Prisma dentro do NestJS

O Prisma Client é uma dependência como qualquer outra — e por isso entra pelo mesmo mecanismo de todas as outras: um provider injetável.

src/prisma/prisma.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(config: ConfigService) {
super({
adapter: new PrismaPg({
connectionString: config.getOrThrow<string>('DATABASE_URL'),
}),
});
}

async onModuleInit(): Promise<void> {
await this.$connect();
}

async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

Três pontos merecem atenção:

  • A importação não é @prisma/client. Na versão 7 o cliente é gerado no caminho declarado em output, e é de lá que ele vem.
  • ConfigService em vez de process.env direto. getOrThrow falha no boot se a variável não existir, em vez de produzir um erro de conexão obscuro na primeira requisição.
  • OnModuleInit e OnModuleDestroy. Conectar na subida e desconectar no encerramento evita conexões penduradas a cada reinício do start:dev.

O módulo, marcado como global para não precisar ser importado em toda parte:

src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@Global() é uma exceção deliberada

A aula 5 insistiu que módulos são fronteiras e que exports/imports devem ser explícitos. @Global() fura essa regra de propósito, e só se justifica para infraestrutura transversal — conexão de banco, configuração, log. Use com parcimônia: um projeto em que tudo é global perdeu as fronteiras que o módulo existia para criar.


Parte 6 — 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, 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.

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 7 — O service, reescrito

Este é o service da aula 6, com a mesma assinatura pública e o mesmo comportamento externo:

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 },
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, categorias: true },
});

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

async criar(dto: CriarLivroDto) {
try {
return await this.prisma.livro.create({ data: dto, include: { autor: 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 },
});
} catch (erro) {
this.traduzirErro(erro, dto.isbn, id);
}
}

async remover(id: number): Promise<void> {
try {
await this.prisma.livro.delete({ where: { id } });
} catch (erro) {
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 informado não existe');
}
}
throw erro;
}
}

Compare com a versão da aula 6 e repare no que mudou:

  • os métodos viraram async e devolvem Promise;
  • LivrosStore desapareceu — o Prisma Client ocupa o lugar dele;
  • a verificação de existência antes de atualizar e remover sumiu, porque o próprio banco a faz e o Prisma sinaliza com P2025;
  • a checagem de ISBN duplicado sumiu, porque a restrição @unique a faz e sinaliza com P2002.

E no que não mudou: as rotas, os DTOs, os códigos de status e o formato da resposta. É esse conjunto que os clientes web e mobile enxergam.

Deixar a restrição no banco não é preguiça

Verificar unicidade em código exige duas idas ao banco (uma para conferir, outra para gravar) e ainda assim não garante nada: entre as duas, outra requisição pode ter gravado o mesmo ISBN. A restrição UNIQUE no banco é atômica. Verificar antes é útil para dar uma mensagem melhor; tratar P2002 é o que garante a integridade.


Parte 8 — Erros do banco viram status HTTP

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
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 7 fez, e é a mesma regra do filtro de exceções da aula 6.


Parte 9 — 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.


Parte 10 — Seed

Um banco vazio impede de testar listagem, filtro e paginação. 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.livro.deleteMany();
await prisma.categoria.deleteMany();
await prisma.autor.deleteMany();
await prisma.leitor.deleteMany();

const romance = await prisma.categoria.create({ data: { nome: 'Romance' } });
const classico = await prisma.categoria.create({ data: { nome: 'Clássico brasileiro' } });

// 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,
categorias: { connect: [{ id: romance.id }, { id: classico.id }] },
exemplares: {
create: [{ tombo: 'DC-001' }, { tombo: 'DC-002' }, { tombo: 'DC-003' }],
},
},
{
titulo: 'Memórias Póstumas de Brás Cubas',
isbn: '9788535914849',
ano: 1881,
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,
categorias: { connect: [{ id: romance.id }] },
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.

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.


Erros comuns

ErroSintomaCorreção
Importar de @prisma/clientModule not found ou tipos ausentesImportar do caminho em output
Esquecer o driver adapterErro ao construir o PrismaClientPassar adapter: new PrismaPg(...)
Alterar o schema e não rodar generateEditor não reconhece o campo novonpx prisma generate
Rodar generate e esquecer a migrationCódigo compila, consulta falha: a coluna não existenpx prisma migrate dev
.env ausente ou sem DATABASE_URLFalha no boot com getOrThrowCriar o .env
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
Usar this.prisma dentro de $transactionA operação não é desfeita no rollbackUsar o tx recebido
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
Gerar o cliente dentro de src/ e versioná-loConflitos absurdos no GitIgnorar o diretório gerado

Laboratório 7 — Persistência de verdade

Continuação direta do laboratório 6. O laboratório tem duas metades com propósitos diferentes:

  • Passos 1 a 8: trocar o armazenamento sem mudar o contrato. Nenhuma funcionalidade nova — o objetivo é verificar a arquitetura.
  • Passos 9 a 14: usar o que só um banco relacional oferece — relacionamentos, transações e consultas agregadas.

Passo 1 — Preparar o banco

A aula assume um PostgreSQL local respondendo em localhost:5432. Crie a base:

createdb biblioteca

Se o comando não existir no seu PATH, use o cliente psql:

psql -U postgres -c "CREATE DATABASE biblioteca;"

Confirme o acesso antes de seguir — resolver problema de conexão agora é bem mais simples do que no meio da primeira migration:

psql -U postgres -d biblioteca -c "SELECT version();"

Passo 2 — Instalar as dependências

npm i @prisma/client @prisma/adapter-pg @nestjs/config dotenv
npm i -D prisma
PacotePapel
@prisma/clientRuntime do cliente gerado
@prisma/adapter-pgDriver adapter para PostgreSQL, obrigatório na v7
@nestjs/configLeitura de variáveis de ambiente na aplicação
dotenvLeitura do .env pela CLI do Prisma e pelo seed
prismaCLI (migrations, generate, studio)

O seed.ts será executado por ts-node, que o Nest CLI já instalou junto com o projeto — não é preciso acrescentar nada para isso.

Se você preferir tsx para rodar o seed

Muitos tutoriais usam tsx no lugar do ts-node. Ele funciona, mas exige um ajuste: o cliente gerado usa a extensão .js nos próprios imports (convenção ESM) e o tsx não consegue resolvê-la para os arquivos .ts em disco, produzindo Cannot find module './internal/class.js'. A correção é declarar importFileExtension = "ts" no bloco generator — o que, por sua vez, quebra o nest build. Usar ts-node evita o conflito inteiro.

Passo 3 — Inicializar o Prisma

npx prisma init --datasource-provider postgresql

Isso cria prisma/schema.prisma e um .env. Ajuste a URL de conexão com o usuário e a senha do seu PostgreSQL:

.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/biblioteca?schema=public"

Confirme que .env está no .gitignore — o Nest CLI já o coloca lá, mas vale verificar. Senha de banco não entra no repositório, mesmo sendo postgres numa máquina local: o hábito é o que importa.

Crie a configuração da CLI na raiz do projeto:

prisma.config.ts
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'ts-node prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
});

Passo 4 — Escrever o primeiro schema

Nesta metade do laboratório o modelo é só o livro, com os mesmos campos que a aula 6 já expunha. Isso é proposital: mantém o contrato intacto e permite verificar a arquitetura antes de acrescentar complexidade.

prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}

datasource db {
provider = "postgresql"
}

model Livro {
id Int @id @default(autoincrement())
titulo String @db.VarChar(200)
autor String @db.VarChar(120)
isbn String @unique @db.VarChar(20)
ano Int
criadoEm DateTime @default(now())
atualizadoEm DateTime @updatedAt
}

Acrescente o diretório gerado ao .gitignore:

.gitignore
/src/generated

Passo 5 — Primeira migration

npx prisma migrate dev --name criar_livro
npx prisma generate

Abra o arquivo em prisma/migrations/<timestamp>_criar_livro/migration.sql e leia o SQL gerado. Localize:

  1. o CREATE TABLE;
  2. o índice único criado por @unique;
  3. o tipo escolhido para id e o mecanismo de autoincremento.

Ler esse SQL é o que impede o ORM de virar caixa-preta.

Passo 6 — Criar o PrismaService

nest g module prisma
nest g service prisma --no-spec

Implemente PrismaService e PrismaModule conforme a Parte 5, e registre o ConfigModule no módulo raiz:

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LivrosModule } from './livros/livros.module';
import { PrismaModule } from './prisma/prisma.module';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
LivrosModule,
],
})
export class AppModule {}

Passo 7 — Reescrever o service

Substitua LivrosService pela versão da Parte 7, adaptada ao schema deste momento — aqui autor ainda é um texto, então o filtro é { autor: { contains: autor, mode: 'insensitive' } }.

Depois, apague src/livros/livros-store.ts e remova-o de providers no LivrosModule. Se algo mais reclamar da remoção, isso indica um acoplamento que não deveria existir — investigue antes de contornar.

Confira o que você NÃO precisou tocar

Antes de seguir, abra livros.controller.ts e os três DTOs e confirme que estão exatamente como estavam no fim do laboratório 6. Essa é a verificação central da aula: a arquitetura das aulas 5 e 6 está correta se, e somente se, esses arquivos não mudaram.

Passo 8 — Repetir os testes da aula 6

Rode os mesmos comandos do passo 9 do laboratório 6, sem alterar nenhum:

curl -i -X POST http://localhost:3000/livros \
-H 'Content-Type: application/json' \
-d '{"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012348","ano":1938}'

curl -i "http://localhost:3000/livros?autor=graciliano"
curl -i http://localhost:3000/livros/9999
curl -i http://localhost:3000/livros/abc

Os status devem ser idênticos aos da aula 6 — 201, 200, 404, 400. Rode o POST duas vezes e confirme que o segundo devolve 409, agora vindo de P2002 em vez de uma verificação em memória.

E a diferença que justifica a aula: reinicie a aplicação e repita o GET. Os dados continuam lá.

Passo 9 — Ampliar o modelo

Agora o domínio cresce. Substitua o schema pelo modelo completo da Parte 3 e gere a segunda migration:

npx prisma migrate dev --name relacionamentos_do_acervo
npx prisma generate

O Prisma vai avisar que a coluna autor está sendo trocada por autorId e que os dados existentes serão perdidos. Em desenvolvimento, aceite.

Isto é uma mudança incompatível de contrato

Trocar autor (texto) por autorId (referência) muda o corpo aceito no POST e no PATCH. Pela aula 4, isso é uma mudança incompatível: um cliente já publicado quebraria. Num sistema em produção, o caminho seria versionar a API ou aceitar as duas formas por um período de transição.

Aqui a mudança é aceitável porque nenhum cliente foi publicado ainda — e o momento de normalizar o modelo é justamente antes disso. Registre a lição: o custo de uma decisão de modelagem cresce depois que existem clientes.

Ajuste os DTOs para o novo contrato:

src/livros/dto/criar-livro.dto.ts
// substitua o campo `autor` por:
@ApiProperty({ example: 1, description: 'Identificador do autor já cadastrado' })
@IsInt()
@Min(1)
autorId!: number;

E ajuste o filtro de listar para atravessar a relação, como na Parte 7: { autor: { nome: { contains: autor, mode: 'insensitive' } } }.

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

npx prisma db seed

Confirme com uma listagem:

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

Cada livro deve vir com o objeto autor aninhado — efeito do include.

Passo 11 — Inspecionar com o Prisma Studio

npx prisma studio

Abre uma interface em http://localhost:5555. Percorra as seis tabelas e confirme visualmente:

  1. os três livros e seus autorId;
  2. a tabela de junção que o Prisma criou sozinho para LivroCategoria (procure pelo nome começando com _);
  3. os exemplares, todos com situação DISPONIVEL.

Encontrar a tabela de junção com os próprios olhos é o que torna concreto o que "N:N implícito" significa.

Passo 12 — Exercitar include, select e agregação

Acrescente ao LivrosService um método que devolve o acervo resumido, com a contagem de exemplares por livro:

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

Exponha em GET /livros/resumoantes 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 13 — 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 conforme a Parte 9 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 três 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.

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 14 — Verificar a documentação

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

  • as operações de livros continuam documentadas, agora com autorId;
  • a operação de empréstimo aparece com o corpo correto;
  • os exemplos dos DTOs foram atualizados.

Critérios de conclusão

Primeira metade — a troca não quebrou nada:

  • a aplicação sobe conectada ao PostgreSQL, sem erro no boot;
  • os cinco endpoints devolvem os mesmos status da aula 6;
  • os dados sobrevivem ao reinício da aplicação;
  • ISBN repetido devolve 409, vindo de P2002;
  • livros.controller.ts e os DTOs não mudaram nesta metade;
  • LivrosStore foi removido do projeto.

Segunda metade — o que só o banco permite:

  • as seis tabelas existem, e a de junção foi criada pelo Prisma;
  • npx prisma db seed popula o banco de forma reproduzível;
  • a listagem traz o autor aninhado com uma única consulta;
  • GET /livros/resumo usa select e _count;
  • emprestar um exemplar indisponível devolve 400;
  • a transação foi verificada: em caso de erro, nada é gravado pela metade;
  • o seu projeto tem os mesmos elementos: relacionamentos, seed e 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. E o resultado mais importante do laboratório não é o banco funcionando — é a primeira metade, em que o armazenamento foi inteiramente substituído sem que uma linha do controller ou dos DTOs mudasse.

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. Uma arquitetura em que a troca de banco obriga a mexer nas rotas transforma qualquer evolução do backend em retrabalho nos dois clientes ao mesmo tempo.

Na aula 8, 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. Explique o que um ORM resolve e cite duas situações concretas em que escrever SQL direto ($queryRaw) seria a escolha melhor.

  2. Sobre modelagem:

    a. Justifique por que Emprestimo é uma entidade e a relação LivroCategoria não precisa ser, e descreva o que aconteceria se um requisito novo pedisse a data em que a categoria foi atribuída.

    b. Escolha a estratégia de onDelete para cada relação do modelo da Parte 3, justificando em termos de negócio.

  3. Descreva as três saídas de prisma migrate dev e explique por que a pasta prisma/migrations entra no Git enquanto src/generated fica de fora.

  4. Diferencie migrate dev de migrate deploy e descreva um cenário concreto em que usar o primeiro no lugar do segundo causaria perda de dados.

  5. 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 } });
    }
  6. 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 e exemplares; (c) exportação completa do acervo para um relatório.

  7. Explique por que verificar a unicidade do ISBN em código, antes de gravar, não substitui a restrição @unique no banco. Descreva a sequência de eventos que produz um ISBN duplicado apesar da verificação.

  8. Mapeie cada código de erro do Prisma (P2002, P2025, P2003) para um status HTTP e escreva a mensagem que você devolveria ao cliente — sem revelar nome de tabela ou coluna.

  9. 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.

  10. Projete o schema do seu estudo de caso com pelo menos: um relacionamento 1:N, um N:N e uma entidade de junção com atributos próprios. Para cada relação, indique a estratégia de onDelete e os índices que você criaria, justificando.


Referências

Principais

Aprofundamento