Aula 8: Modelagem de relacionamentos com Prisma
A aula 7 provou que a arquitetura aguenta trocar o armazenamento — mas fez isso
com o modelo mais pobre possível: um Livro sem nenhuma relação, com autor
como texto solto. Esta aula substitui esse modelo pelo acervo completo da
biblioteca e, no caminho, implementa um exemplo de cada tipo de
relacionamento que o Prisma trata de forma diferente.
São seis, e a diferença entre eles não é de estilo: cada um produz um SQL distinto, exige uma escrita distinta no schema e falha de um jeito distinto quando está errado. Saber escrever os seis é o que permite modelar um domínio qualquer — que é o que você vai precisar fazer no seu próprio projeto.
Continua sendo um exercício de modelagem antes de ser um exercício de código: cada relação exige três decisões — quantos de cada lado, se o vínculo pode não existir, e o que acontece ao apagar um dos lados — e as três são de negócio, não do framework.
| O que vem depois | Onde |
|---|---|
| Consultas, transações e seed sobre este modelo | Aula 9 — Consultas, transações e seed |
| Autenticação, guards e proteção de rotas | Aula 10 — Autenticação e autorização |
| Consumo desta API pelo cliente web | Módulo 3 — Next.js |
| Consumo da mesma API pelo cliente mobile | Módulo 4 — Flutter |
Objetivos
Ao final desta aula, você deve ser capaz de:
- Identificar, a partir de um requisito de domínio, qual dos seis tipos de relacionamento o descreve.
- Implementar no schema do Prisma cada um dos seis: 1:N obrigatório, 1:N opcional, N:N implícito, junção explícita, 1:1 e auto-relacionamento.
- Ler o SQL de uma migration e localizar nele a coluna de chave estrangeira, a restrição de unicidade, a tabela de junção e a cláusula
ON DELETE. - Escolher a estratégia de
onDelete(Restrict,Cascade,SetNull) coerente com cada relação e justificar a escolha em termos de negócio. - Diagnosticar os erros que o Prisma acusa quando obrigatoriedade, unicidade ou nome de relação estão incoerentes.
- Reconhecer quando uma mudança de modelo é uma mudança incompatível de contrato, e ajustar DTOs e filtros de acordo.
Ambiente sugerido
Mesmo ambiente da aula 7 — Node 22 LTS, NestJS 11, Prisma 7, PostgreSQL 16 — e
nenhuma dependência nova entra nesta aula. O projeto continua exatamente de onde
o laboratório 7 o deixou: um Livro isolado, sem relações.
Parte 1 — Como o Prisma escreve uma relação
Antes dos seis tipos, três ideias que valem para todos eles.
Toda relação é escrita duas vezes
No banco, uma relação 1:N é uma coluna: Livro.autorId. No schema do
Prisma, ela aparece como dois campos no mesmo model:
model Livro {
autorId Int // campo escalar
autor Autor @relation(fields: [autorId], references: [id]) // campo de relação
}
- O campo escalar (
autorId) é a coluna de verdade: existe na tabela, tem tipo, aceita ou nãonull, e é o que o SQL enxerga. - O campo de relação (
autor) não é coluna nenhuma. É a janela pela qual o cliente do Prisma enxerga o outro model — é o que permite escreverinclude: autorna aula 9.
Do outro lado, Autor.livros também não é coluna: é a mesma relação vista de
cima. Confundir os dois é o erro conceitual mais comum no começo, e ele produz
uma pergunta previsível: "por que a lista livros não aparece na tabela
Autor?". Porque ela nunca esteve lá.
@relation mora do lado da chave estrangeira@relation(fields: [...], references: [...]) só é escrito no lado que guarda
a coluna. O outro lado declara apenas a lista (ou o campo opcional, no caso do
1:1). Escrever fields/references nos dois lados é recusado na validação do
schema, com uma mensagem que aponta exatamente o campo oposto onde eles
deveriam estar.
As três perguntas de toda relação
Cada relação do modelo abaixo foi decidida respondendo, nesta ordem:
- Quantos de cada lado? Um autor tem muitos livros; um livro tem um autor. Isso é a cardinalidade, e ela decide a forma do schema.
- O vínculo pode não existir? Todo livro tem autor; nem todo livro tem editora cadastrada. Isso é a obrigatoriedade, e ela decide o
?. - O que acontece ao apagar o outro lado? Isso é o
onDelete, e só faz sentido perguntar depois de responder a segunda.
As três são perguntas sobre a biblioteca, não sobre o Prisma. É por isso que elas se transportam para qualquer outro domínio.
O modelo que vamos construir
A Figura 1 mostra o destino; as partes 2 a 7 constroem uma relação de cada vez. Cada parte segue a mesma sequência: o requisito de domínio, o schema que o implementa, o SQL que ele gera e a armadilha correspondente.
| Tipo | Onde aparece na biblioteca | Parte |
|---|---|---|
| 1:N obrigatório | Autor → Livro, Livro → Exemplar | 2 |
| 1:N opcional | Editora → Livro | 3 |
| N:N implícito | Livro ↔ Categoria | 4 |
| Junção explícita | Exemplar + Leitor → Emprestimo | 5 |
| 1:1 | Livro → FichaCatalografica | 6 |
| Auto-relacionamento | Categoria → Categoria | 7 |
Os trechos de SQL abaixo reproduzem o essencial do que o Prisma gera para
PostgreSQL: a coluna, a restrição e a cláusula ON DELETE. Nomes de constraint
e de índice seguem a convenção do Prisma e podem variar entre versões — o
arquivo que vale é o que você gerou, e lê-lo é um passo do laboratório.
Parte 2 — 1:N obrigatório
Requisito: todo livro do acervo tem exatamente um autor; um autor pode ter vários livros no acervo — ou nenhum, se acabou de ser cadastrado.
É o tipo mais comum, e o ponto de partida dos outros cinco.
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)
// ...
autorId Int
autor Autor @relation(fields: [autorId], references: [id], onDelete: Restrict)
@@index([autorId])
}
A chave estrangeira mora no lado N. É Livro que guarda autorId, não
Autor que guarda uma lista — uma coluna não comporta muitos valores. Essa é a
regra que decide, em qualquer 1:N, de que lado escrever o @relation: do lado
que tem muitos.
Nenhum ? em lugar nenhum. autorId Int e autor Autor — sem interrogação
no escalar nem no campo de relação. É isso que torna a relação obrigatória: o
banco recusa um livro sem autor.
O SQL correspondente:
-- a coluna, no lado N
"autorId" INTEGER NOT NULL,
-- o índice, porque a coluna vai ser usada como filtro
CREATE INDEX "Livro_autorId_idx" ON "Livro"("autorId");
-- a restrição de integridade, com a estratégia escolhida
ALTER TABLE "Livro" ADD CONSTRAINT "Livro_autorId_fkey"
FOREIGN KEY ("autorId") REFERENCES "Autor"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
onDelete: a mesma forma, decisões opostas
A escolha de onDelete é de negócio, e o modelo tem os dois casos no mesmo
tipo de relação:
| Estratégia | Efeito ao apagar o lado 1 | Quando usar |
|---|---|---|
Restrict | A operação falha se houver dependentes | O dependente tem valor próprio |
Cascade | Os dependentes são apagados junto | O dependente não faz sentido sozinho |
SetNull | A chave estrangeira vira null | O vínculo é opcional (Parte 3) |
Apagar um autor não pode apagar a obra dele — o livro continua existindo no
acervo mesmo que o cadastro do autor saia. Daí Restrict. Já um exemplar é uma
cópia física de um livro específico: apagado o livro, os exemplares não
significam mais nada. Daí Cascade:
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)
@@index([livroId])
}
Duas relações do mesmo tipo, dois ON DELETE diferentes no mesmo arquivo de
migration. Se você trocasse as duas de lugar, o schema continuaria válido, o
build continuaria passando — e a biblioteca perderia o acervo de um autor
descadastrado. Nenhuma ferramenta pega esse erro.
Restrict vira um erro em tempo de execução, não de compilaçãoCom Restrict, apagar um autor que tem livros devolve o código P2003
(violação de chave estrangeira) em plena requisição. Traduzir esse código para
um status HTTP adequado é assunto da aula 9 — aqui basta saber que a decisão de
modelagem é o que cria esse erro, de propósito.
Procure o par mais óbvio do seu domínio — pedido e item, turma e aluno,
publicação e comentário — e pergunte: se o lado "um" for apagado, o lado
"muitos" ainda quer dizer alguma coisa? Se sim, Restrict. Se não, Cascade.
Escreva a justificativa em uma frase antes de escrever o schema; se a frase não
sair, a modelagem ainda não está decidida.
Parte 3 — 1:N opcional
Requisito: um livro pode ter uma editora cadastrada, mas não precisa — edições antigas, doações e registros incompletos existem em qualquer acervo. Uma editora pode ter vários livros.
Mesma cardinalidade da Parte 2. O que muda é a resposta à segunda pergunta.
model Editora {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(120)
livros Livro[]
}
model Livro {
// ...
editoraId Int?
editora Editora? @relation(fields: [editoraId], references: [id], onDelete: SetNull)
@@index([editoraId])
}
O ? aparece duas vezes, e as duas são necessárias: em editoraId Int? — a
coluna aceita null — e em editora Editora? — o campo de relação também é
opcional, refletindo que a consulta pode não encontrar nada do outro lado.
Faltando qualquer um dos dois, o Prisma recusa o schema com uma mensagem
explícita.
No SQL, a diferença é uma palavra:
-- Parte 2, obrigatório
"autorId" INTEGER NOT NULL,
-- Parte 3, opcional
"editoraId" INTEGER,
ALTER TABLE "Livro" ADD CONSTRAINT "Livro_editoraId_fkey"
FOREIGN KEY ("editoraId") REFERENCES "Editora"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
onDelete: SetNull só é uma opção válida porque a relação é opcional — não
há como gravar null numa coluna NOT NULL. E é o comportamento certo aqui:
remover uma editora do cadastro não deveria apagar os livros dela nem travar a
remoção só porque ela publicou alguma coisa. O livro continua existindo; só
perde a referência.
onDelete são perguntas separadasA primeira é "esse vínculo pode não existir?" e decide o ?. A segunda é "o que
fazer quando o outro lado for apagado?" e decide o onDelete. SetNull
exige relação opcional; Restrict e Cascade servem para os dois casos, mas
costumam aparecer em relações obrigatórias, onde não existe a saída de
simplesmente desconectar.
Escrever onDelete: SetNull numa relação obrigatória é um erro de
modelagem, mas npx prisma validate aceita o schema: a incoerência só
aparece no momento em que alguém tenta apagar o outro lado, quando o banco
recusa gravar null numa coluna NOT NULL. Já a incoerência entre os dois ?
— escalar opcional com campo de relação obrigatório, ou o contrário — é pega na
hora. Vale conhecer a diferença: ferramenta boa reduz a superfície de erro, não
a elimina.
Índices também na chave estrangeira opcional. @@index([editoraId]) cobre a
busca de livros por editora e a consulta "livros sem editora cadastrada"
(editoraId: null), que a aula 9 usa. Sem índice, o PostgreSQL varre a tabela
inteira — imperceptível com os poucos registros de um seed, caro em produção.
onDelete não é declaradoO Prisma aplica um padrão que depende da obrigatoriedade: Restrict em relação
obrigatória, SetNull em relação opcional (e Cascade no onUpdate dos dois
casos). Omitir não é "sem regra" — é aceitar esse padrão. O modelo desta aula
declara quase todos explicitamente justamente para tornar a decisão visível.
Relação opcional é a que mais aparece em cadastro de verdade, porque dado incompleto é a regra, não a exceção. Procure no seu domínio um campo que hoje você guardaria como texto solto "para preencher depois" — ele provavelmente é uma relação opcional esperando ser normalizada.
Parte 4 — N:N implícito
Requisito: um livro pode estar em várias categorias; uma categoria reúne vários livros. E, por enquanto, não há nada a registrar sobre a atribuição em si.
model Categoria {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(60)
livros Livro[]
}
model Livro {
// ...
categorias Categoria[]
}
É só isso: uma lista de cada lado, e nada mais. Nenhum campo escalar, nenhum
@relation, nenhuma tabela intermediária declarada. O Prisma cria e mantém a
junção sozinho — por isso "implícito".
O SQL revela a tabela que você não escreveu:
CREATE TABLE "_CategoriaToLivro" (
"A" INTEGER NOT NULL, -- Categoria.id
"B" INTEGER NOT NULL -- Livro.id
);
-- o par (A,B) é único: a mesma categoria não entra duas vezes no mesmo livro
-- índice adicional em "B", para a busca no sentido inverso
-- duas chaves estrangeiras, ambas em cascata: apagado um dos lados, a linha
-- da junção some junto
Três coisas para reparar no nome e na forma dessa tabela:
- O nome é derivado dos dois models em ordem alfabética, com um sublinhado
na frente:
_CategoriaToLivro.@relation("NomeQueVocêEscolher")nos dois lados troca esse nome, se você precisar. - As colunas se chamam
AeB, e nãocategoriaId/livroId. Elas são do Prisma, não suas. - Não há onde pendurar um atributo. Essa é a limitação inteira do modo implícito, e é o que motiva a Parte 5.
Na prática, a tabela some do seu campo de visão: você escreve
categorias: { connect: [{ id: 1 }] } e lê include: { categorias: true } (aula
9), sem nunca mencionar _CategoriaToLivro.
O modo implícito é ótimo enquanto a relação for só um vínculo. No dia em que aparecer o requisito "registrar quem atribuiu a categoria e quando", não há coluna onde escrever isso: a migração para junção explícita passa a exigir criar a tabela nova, copiar os dados da antiga e descartá-la. Com o banco vazio custa um comando; com um semestre de dados, custa um script revisado.
Antes de escolher implícito, faça o teste da Parte 5: tente imaginar um atributo da própria relação. Se conseguir imaginar com facilidade, escolha explícito desde já — mesmo que o atributo ainda não seja pedido.
Parte 5 — Junção explícita
Requisito: um leitor toma exemplares emprestados. Cada empréstimo tem data de retirada, data prevista de devolução e, quando devolvido, data de devolução.
A ligação entre Exemplar e Leitor tem atributos próprios — as três datas
não pertencem nem ao exemplar nem ao leitor, mas ao empréstimo. Assim que isso
acontece, a relação deixa de caber numa tabela automática e vira entidade:
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])
}
Repare que não existe tipo novo aqui: uma junção explícita é apenas duas relações 1:N obrigatórias (Parte 2) convergindo num model que também tem campos próprios. Você já sabe escrever cada metade; o que muda é o reconhecimento de que a relação merecia virar 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.
O efeito em cadeia que ninguém desenha
Emprestimo.exemplar e Emprestimo.leitor não declaram onDelete. Como as duas
são obrigatórias, o padrão é Restrict: apagar um Exemplar ou um Leitor com
empréstimo vinculado falha.
Combine isso com o Cascade da Parte 2 e aparece um comportamento que só se
enxerga lendo o modelo inteiro:
- apagar um
Livrotenta apagar seusExemplarem cascata; - mas cada
Exemplarcom empréstimo é barrado porRestrict; - logo, a exclusão do livro falha — e falha por causa de uma tabela que nem aparece no comando.
Não é defeito: é a integridade referencial fazendo o trabalho dela. A aula 9 volta a esse caso para traduzir o erro resultante em um status HTTP honesto.
A variante com chave primária composta
Nem toda junção explícita precisa de id próprio. Quando a entidade de junção
não tem vida independente, a chave primária pode ser o par de chaves
estrangeiras:
model LivroCategoria {
livroId Int
categoriaId Int
atribuidoEm DateTime @default(now())
livro Livro @relation(fields: [livroId], references: [id], onDelete: Cascade)
categoria Categoria @relation(fields: [categoriaId], references: [id], onDelete: Cascade)
@@id([livroId, categoriaId])
@@index([categoriaId])
}
Essa é a forma "N:N explícito" propriamente dita — o equivalente manual de
_CategoriaToLivro, com espaço para atributos. Emprestimo não usa essa
forma de propósito: o mesmo exemplar pode ser emprestado ao mesmo leitor mais de
uma vez ao longo do tempo, e uma chave primária (exemplarId, leitorId)
proibiria o segundo empréstimo. A escolha entre id próprio e chave composta é,
mais uma vez, uma pergunta de domínio: o par pode se repetir?
Se o seu domínio tem um N:N, escreva as duas versões no papel — implícita e explícita — e decida pelo teste do atributo e pelo teste da repetição. Anote a decisão: ela é uma das que mais custam para reverter.
Parte 6 — 1:1
Requisito: cada livro pode ter uma ficha catalográfica — classificação, idioma, número de páginas, sinopse. Uma ficha descreve exatamente um livro.
O 1:1 é o tipo que mais surpreende, porque no schema ele parece um 1:N — e é uma única palavra que faz a diferença.
model FichaCatalografica {
id Int @id @default(autoincrement())
cdd String? @db.VarChar(20)
idioma String @default("pt-BR") @db.VarChar(10)
paginas Int?
sinopse String? @db.Text
livroId Int @unique
livro Livro @relation(fields: [livroId], references: [id], onDelete: Cascade)
}
model Livro {
// ...
ficha FichaCatalografica?
}
O @unique sobre a chave estrangeira é o 1:1 inteiro. É ele que impede duas
fichas apontarem para o mesmo livro. Removê-lo não passa despercebido: o Prisma
recusa o schema com uma mensagem que já resume a regra — uma relação
um-para-um precisa de campo único do lado que a define; ou acrescente @unique
ao campo, ou mude a relação para um-para-muitos.
O que passa despercebido é aceitar a segunda saída sem pensar: trocar
ficha FichaCatalografica? por uma lista FichaCatalografica[] também faz o
schema validar — e transforma o modelo em 1:N calado. O validador garante que
os dois lados sejam coerentes entre si; que a cardinalidade seja a que o domínio
pede, quem garante é você.
Compare o SQL com o da Parte 2:
-- Parte 2 (1:N): índice comum
CREATE INDEX "Livro_autorId_idx" ON "Livro"("autorId");
-- Parte 6 (1:1): índice ÚNICO — é ele que impede o segundo registro
CREATE UNIQUE INDEX "FichaCatalografica_livroId_key" ON "FichaCatalografica"("livroId");
O lado sem chave estrangeira é obrigatoriamente opcional. Livro.ficha é
FichaCatalografica?, e o ? aí não é escolha sua: o Prisma exige que o lado
que não guarda a coluna seja opcional, porque não há nada no banco que garanta a
existência da ficha. Se a regra de negócio disser "toda obra tem ficha", quem
faz valer essa regra é a aplicação — o schema não consegue.
Os dois lados são tecnicamente possíveis; a escolha é prática. Coloque a coluna
no lado menos frequente ou mais acessório — aqui, na ficha, não no livro.
Assim a tabela Livro, lida em toda listagem, não carrega uma coluna que quase
sempre estaria vazia, e apagar a ficha não exige tocar no livro.
Por que um 1:1 existe
A pergunta legítima: se é um para um, por que não colocar cdd, sinopse e
paginas como colunas de Livro? Três razões que aparecem juntas na prática:
- Tamanho.
sinopseé texto longo, lido raramente;tituloeisbnsão lidos em toda listagem. Separar mantém a tabela quente pequena. - Preenchimento. A ficha existe para parte do acervo, não para todo ele.
Como colunas de
Livro, seriam quatro campos nulos na maioria das linhas. - Ciclo de vida próprio. A ficha é produzida pela catalogação, em outro momento e possivelmente por outra pessoa, que o modelo pode um dia querer registrar.
Quando nenhuma das três se aplica, o 1:1 é ruído: use colunas comuns.
1:1 quase nunca aparece como requisito explícito — ele aparece como "esses campos aqui são meio à parte". Se, no seu domínio, um bloco de campos é raro, grande ou preenchido em outro momento, ele é candidato a 1:1.
Parte 7 — Auto-relacionamento
Requisito: categorias se organizam em hierarquia. "Romance" e "Clássico brasileiro" ficam sob "Literatura"; "Literatura" não fica sob nada.
Uma relação entre um model e ele mesmo. A forma é a de um 1:N opcional (Parte 3), com uma exigência a mais:
model Categoria {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(60)
livros Livro[]
paiId Int?
pai Categoria? @relation("HierarquiaDeCategorias", fields: [paiId], references: [id], onDelete: SetNull)
subcategorias Categoria[] @relation("HierarquiaDeCategorias")
@@index([paiId])
}
O nome da relação é obrigatório. @relation("HierarquiaDeCategorias")
aparece nas duas pontas, com exatamente o mesmo texto. Sem ele, o Prisma vê
dois campos apontando para Categoria no mesmo model e não tem como saber que
pai e subcategorias são as duas metades de uma mesma relação — o schema é
recusado. O nome em si não vai para o banco; é só o par que ele forma.
paiId tem de ser anulável — a categoria raiz não tem categoria-pai. Um
auto-relacionamento hierárquico obrigatório seria impossível de povoar: a
primeira linha não teria para onde apontar.
O SQL é o de sempre, com a particularidade de a tabela referenciar a si mesma:
"paiId" INTEGER,
ALTER TABLE "Categoria" ADD CONSTRAINT "Categoria_paiId_fkey"
FOREIGN KEY ("paiId") REFERENCES "Categoria"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
SetNull aqui tem uma leitura de domínio diferente da da Parte 3: apagar
"Literatura" não desvincula uma informação perdida — ela promove as filhas à
raiz. Mesma cláusula SQL, significados distintos. Cascade também seria
defensável (apagar a subárvore inteira), e é uma decisão que vale discutir antes
de escrever.
Nada na restrição de chave estrangeira proíbe a categoria A ter como mãe a
categoria B que tem como mãe a categoria A. Consistência de hierarquia é
responsabilidade da aplicação. Vale também lembrar que consultar uma árvore de
profundidade arbitrária exige include aninhado nível a nível ou uma consulta
recursiva em SQL — o Prisma não resolve isso sozinho.
Em PostgreSQL, SetNull e Cascade funcionam normalmente num
auto-relacionamento. Alguns bancos — SQL Server, notadamente — recusam ações
referenciais que formem ciclo e exigem NoAction em uma das pontas. Se o seu
projeto usar outro banco, confira antes.
Auto-relacionamento aparece em quase todo domínio: categoria e subcategoria, funcionário e chefe, comentário e resposta, pasta e subpasta. Se o seu tem um, implemente-o — é o tipo com maior chance de você encontrar material antigo errado na internet, justamente por causa do nome de relação.
Parte 8 — Os seis tipos, lado a lado
Com as seis partes escritas, o schema completo é a soma delas:
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 Editora {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(120)
livros Livro[]
}
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)
editoraId Int?
editora Editora? @relation(fields: [editoraId], references: [id], onDelete: SetNull)
categorias Categoria[]
exemplares Exemplar[]
ficha FichaCatalografica?
criadoEm DateTime @default(now())
atualizadoEm DateTime @updatedAt
@@index([autorId])
@@index([editoraId])
}
model FichaCatalografica {
id Int @id @default(autoincrement())
cdd String? @db.VarChar(20)
idioma String @default("pt-BR") @db.VarChar(10)
paginas Int?
sinopse String? @db.Text
livroId Int @unique
livro Livro @relation(fields: [livroId], references: [id], onDelete: Cascade)
}
model Categoria {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(60)
livros Livro[]
paiId Int?
pai Categoria? @relation("HierarquiaDeCategorias", fields: [paiId], references: [id], onDelete: SetNull)
subcategorias Categoria[] @relation("HierarquiaDeCategorias")
@@index([paiId])
}
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])
}
E o resumo que vale levar para qualquer domínio:
| Tipo | Como se escreve | Onde mora a chave estrangeira | O que garante a cardinalidade |
|---|---|---|---|
| 1:N obrigatório | escalar + @relation, sem ? | no lado N | NOT NULL na coluna |
| 1:N opcional | escalar Int? + relação Model? | no lado N | coluna anulável |
| N:N implícito | só uma lista de cada lado | numa tabela criada pelo Prisma | unicidade do par na tabela de junção |
| Junção explícita | model próprio com duas 1:N | nas duas colunas do model do meio | a chave primária escolhida |
| 1:1 | como 1:N, mais @unique na FK | no lado acessório | o índice único sobre a FK |
| Auto-relacionamento | 1:N opcional apontando para o próprio model | no próprio model | nome de relação nas duas pontas |
Parte 9 — O contrato muda: DTOs e filtro
Aplicar este schema por cima do modelo mínimo da aula 7 significa trocar
autor: String por autorId: Int — e acrescentar editoraId.
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.
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsISBN, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CriarLivroDto {
@ApiProperty({ example: 'Dom Casmurro', maxLength: 200 })
@IsString()
@IsNotEmpty({ message: 'o título é obrigatório' })
@MaxLength(200)
titulo!: string;
@ApiProperty({ example: '9788525406958' })
@IsISBN()
isbn!: string;
@ApiProperty({ example: 1899, minimum: 1450 })
@IsInt()
@Min(1450)
@Max(2100)
ano!: number;
@ApiProperty({ example: 1, description: 'Identificador do autor já cadastrado' })
@IsInt()
@Min(1)
autorId!: number;
@ApiPropertyOptional({ example: 1, description: 'Identificador da editora, se houver' })
@IsOptional()
@IsInt()
@Min(1)
editoraId?: number;
}
editoraId combina @IsOptional() com @IsInt(), exatamente como o schema
combina Int? com a relação opcional — a mesma decisão de obrigatoriedade,
repetida em duas camadas. Sem @IsOptional(), um POST que não informar
editora seria rejeitado com 400, mesmo o campo sendo legitimamente ausente.
O filtro de listar também muda, para atravessar a relação em vez de comparar
texto:
const where: Prisma.LivroWhereInput = autor
? { autor: { nome: { contains: autor, mode: 'insensitive' } } }
: {};
AtualizarLivroDto continua sendo PartialType(CriarLivroDto) — não precisa de
mudança própria: todos os campos, incluindo os dois novos, já nascem opcionais
por herdar do PartialType.
categorias, ficha e exemplares existem no schema mas não aparecem no
DTO. Criá-los junto com o livro exige escrita aninhada (connect, create
aninhado), que é assunto da aula 9. Esta aula para no ponto em que o modelo está
correto — o contrato só acompanha as duas chaves estrangeiras que o próprio
Livro passou a carregar.
Erros comuns
| Erro | Sintoma | Correção |
|---|---|---|
? só no escalar ou só no campo de relação | O Prisma recusa o schema ao validar | Os dois, ou nenhum |
@relation(fields:, references:) nos dois lados | Erro de validação do schema | Só no lado que guarda a coluna |
1:1 sem @unique na chave estrangeira | O Prisma recusa o schema e oferece duas saídas; aceitar a segunda vira 1:N sem querer | @unique no campo escalar da FK |
| Lado sem FK declarado obrigatório num 1:1 | O Prisma recusa o schema | O lado sem FK é sempre opcional |
| Auto-relacionamento sem nome de relação | O Prisma não consegue parear os campos | @relation("Nome") idêntico nas duas pontas |
SetNull em relação obrigatória | O schema é aceito pelo validador; a falha só aparece ao apagar o outro lado | SetNull exige coluna anulável |
| Chave estrangeira sem índice | Consulta lenta só sob volume | @@index([campo]) em toda FK usada como filtro |
| N:N implícito quando a relação tem atributo | Descoberto tarde, com dados gravados | Teste do atributo antes de escolher |
Uniformizar onDelete no modelo inteiro | Perda de dados ou exclusão travada, conforme o caso | Uma decisão de negócio por relação |
Laboratório 8 — Um tipo de relacionamento por vez
Continuação direta do laboratório 7. Ao final, o schema representa o acervo inteiro — ainda sem dados: popular o banco e consultar as relações fica para a aula 9.
O roteiro é incremental de propósito. Colar o schema completo de uma vez produz exatamente o mesmo banco, e ensina bem menos: com uma migration por tipo, o SQL de cada um aparece isolado em um arquivo curto, que dá para ler inteiro.
Depois de cada migrate dev, abra o arquivo
prisma/migrations/<timestamp>_<nome>/migration.sql e leia-o antes de seguir
para o passo seguinte. São de 5 a 20 linhas por vez. É a única oportunidade do
semestre de ver, isolado, o SQL que cada decisão de modelagem produz.
Passo 1 — Confirmar o ponto de partida
Você deve estar no projeto do laboratório 7: um único model Livro, com autor
ainda como texto, e o banco biblioteca respondendo. Confirme:
npx prisma migrate status
npm run start:dev
A aplicação sobe e GET /livros responde. Se não, resolva isso antes de
continuar — cada passo daqui em diante depende do anterior.
Passo 2 — 1:N obrigatório (Autor e Exemplar)
Acrescente ao prisma/schema.prisma o model Autor, o enum SituacaoExemplar e
o model Exemplar, conforme a Parte 2. Em Livro, troque o campo autor
(texto) pelo par autorId + autor, acrescente a lista exemplares e o índice
@@index([autorId]).
npx prisma migrate dev --name relacoes_obrigatorias
O Prisma vai avisar que a coluna autor está sendo trocada por autorId e que
os dados existentes serão perdidos. Em desenvolvimento, aceite.
No migration.sql gerado, localize:
- a coluna
"autorId" INTEGER NOT NULLemLivro; - as duas cláusulas
ON DELETE— umaRESTRICT, umaCASCADE. Diga em voz alta qual é de qual relação e por quê antes de seguir.
Passo 3 — 1:N opcional (Editora)
Acrescente o model Editora e, em Livro, o par editoraId + editora com
onDelete: SetNull, mais @@index([editoraId]).
npx prisma migrate dev --name editora_opcional
No SQL, compare a nova coluna com a do passo anterior:
"autorId" INTEGER NOT NULL -- passo 2
"editoraId" INTEGER -- passo 3, sem NOT NULL
Encontrar essa diferença com os próprios olhos é o que torna concreto o que "relacionamento opcional" significa no banco.
Experimento (2 minutos): remova o ? de editora Editora?, deixando
editoraId Int? como está, e rode npx prisma validate. O Prisma recusa o
schema e explica por quê — é a prova de que os dois ? são um par, não uma
redundância. Desfaça e siga.
Agora o contrário, para ver o limite da ferramenta: troque o onDelete: Restrict
de Livro.autor por onDelete: SetNull e valide de novo. O schema passa —
SetNull numa relação obrigatória só falha na hora de apagar um autor. Desfaça
e siga.
Passo 4 — N:N implícito (Categoria)
Acrescente o model Categoria com id e nome, e a lista livros. Em Livro,
acrescente a lista categorias. Nada mais: nenhum escalar, nenhum
@relation.
npx prisma migrate dev --name categorias_nn
No SQL, encontre a tabela cujo nome começa com sublinhado. Responda:
- como ela se chama, e de onde veio esse nome;
- como se chamam as duas colunas;
- o que impede a mesma categoria ser atribuída duas vezes ao mesmo livro.
Passo 5 — Auto-relacionamento (hierarquia de categorias)
Ainda em Categoria, acrescente paiId, pai e subcategorias, com o nome de
relação nas duas pontas, conforme a Parte 7.
npx prisma migrate dev --name hierarquia_de_categorias
No SQL, repare que a chave estrangeira referencia a própria tabela.
Experimento (2 minutos): remova o @relation("HierarquiaDeCategorias") de
uma das duas pontas e rode npx prisma validate. A mensagem de erro é a melhor
explicação possível de por que o nome existe. Recoloque e siga.
Passo 6 — 1:1 (FichaCatalografica)
Acrescente o model FichaCatalografica, com livroId Int @unique, e em Livro
o campo ficha FichaCatalografica?.
npx prisma migrate dev --name ficha_catalografica
No SQL, compare os dois índices:
CREATE INDEX "Livro_autorId_idx" ON "Livro"("autorId"); -- 1:N
CREATE UNIQUE INDEX "FichaCatalografica_livroId_key" ON "FichaCatalografica"("livroId"); -- 1:1
Experimento (2 minutos): faça os dois e leia as mensagens antes de desfazer.
- Remova o
?deLivro.fichae rodenpx prisma validate: o Prisma explica que o lado sem chave estrangeira não pode ser obrigatório. - Recoloque o
?e remova o@uniquedelivroId. O Prisma recusa de novo, e a mensagem oferece duas saídas. Só uma delas preserva o 1:1 — identifique qual, e o que a outra faria com o seu modelo.
Passo 7 — Junção explícita (Leitor e Emprestimo)
Acrescente os models Leitor e Emprestimo, e a lista emprestimos em
Exemplar, conforme a Parte 5. Não declare onDelete nas duas relações de
Emprestimo.
npx prisma migrate dev --name emprestimos
npx prisma generate
No SQL, descubra qual ON DELETE o Prisma aplicou sozinho às duas chaves
estrangeiras — e explique, em uma frase, o efeito em cadeia que isso produz
junto com o CASCADE do passo 2.
Passo 8 — Ajustar o contrato
Aplique a Parte 9: substitua CriarLivroDto pela versão com autorId e
editoraId, e ajuste o filtro de listar no LivrosService para atravessar a
relação com o autor.
Passo 9 — Conferir o modelo inteiro
npx prisma migrate status
npm run start:dev
npx prisma studio
migrate status deve listar as seis migrations aplicadas, na ordem em que
você as criou — esse histórico é o registro da construção, e é ele que permite a
qualquer pessoa reproduzir o banco do zero.
A aplicação deve subir sem erro, mesmo sem nenhum dado (nenhum Autor existe
para um POST referenciar). No Prisma Studio, percorra as tabelas e confirme:
- as oito entidades aparecem, e todas estão vazias;
LivrotemautorId(obrigatório) eeditoraId(aceita vazio);FichaCatalograficatemlivroIdcom restrição de unicidade;CategoriatempaiId, apontando para a própria tabela.
A tabela de junção implícita não aparece no Studio: ele lista os models do
schema, e _CategoriaToLivro não é um model. Para vê-la, use o SQL do passo 4
ou o cliente do banco de sua preferência — em psql, \dt lista todas as
tabelas, inclusive as que o Prisma administra.
A aula 9 volta ao Studio depois do seed, para ver as relações preenchidas.
Atividades propostas
- No seu projeto, implemente pelo menos quatro dos seis tipos, um de
cada vez, com uma migration por tipo, como neste laboratório. Para cada
relação, escreva antes uma frase justificando a obrigatoriedade e o
onDeleteem termos do seu domínio. Se algum dos seis tipos não existir no seu domínio, justifique a ausência — isso também é modelagem. - Converta
Livro–Categoriade N:N implícito para junção explícita, com um modelLivroCategoriaque tenhaatribuidoEme chave primária composta. Gere a migration e compare o SQL das duas formas, lado a lado. - Reescreva
FichaCatalograficausando a chave estrangeira como chave primária (livroId Int @id, semidpróprio). Gere a migration, diga o que muda no SQL e o que se perde com essa escolha. - Sem consultar a Parte 8, escreva de memória a tabela de
onDelete(Restrict,Cascade,SetNull) e aplique-a a cada relação do modelo, incluindo as que não declaramonDelete. Depois confira — errar aqui é mais instrutivo do que copiar.
Critérios de conclusão
- o schema tem as oito entidades da Parte 8, com um exemplo de cada um dos seis tipos de relacionamento;
- as seis migrations foram geradas na ordem, e
npx prisma migrate statusas lista todas como aplicadas; - o SQL de cada migration foi lido, e foram localizados: a coluna
NOT NULLdo 1:N obrigatório, a coluna anulável do opcional, a tabela de junção implícita, o índice único do 1:1 e a chave estrangeira que aponta para a própria tabela; - as três estratégias de
onDeleteaparecem no modelo, cada uma com justificativa de negócio; - os experimentos dos passos 3, 5 e 6 foram feitos, as mensagens do Prisma foram lidas, e ficou claro qual incoerência o validador pega e qual só aparece em tempo de execução;
-
CriarLivroDtousaautorIdobrigatório eeditoraIdopcional (@IsOptional()); - o filtro de
listaratravessa a relação comautor.nome; - a aplicação sobe sem erro, as oito entidades aparecem (vazias) no Prisma Studio, e a tabela de junção implícita foi localizada no banco — não no Studio.
Fechamento
O modelo agora é o acervo completo, com um exemplo de cada tipo de relacionamento
— mas está vazio, e o service ainda não sabe tirar proveito de nenhuma relação:
não há include, não há escrita aninhada, não há consulta agregada, não há
transação. A aula 9 popula o banco, reescreve o service para consumir as
relações modeladas aqui e trata os erros que só existem quando há chave
estrangeira.
Exercícios (Checkpoints)
-
Explique por que a lista
Autor.livrosnão corresponde a nenhuma coluna da tabelaAutor, e descreva onde a informação dessa relação está de fato guardada. -
Justifique por que
Emprestimoé uma entidade e a relaçãoLivro–Categorianão precisa ser, e descreva o que aconteceria se um requisito novo pedisse a data em que a categoria foi atribuída. -
Explique a diferença entre uma relação obrigatória e uma opcional em três níveis: o tipo do campo no schema (
Int×Int?), o SQL gerado (NOT NULL× anulável) e o decorator do DTO (@IsOptional()). Por que os três precisam concordar? -
Justifique por que
SetNullsó faz sentido em relação opcional e descreva em que momento o problema apareceria se você o aplicasse aLivro.autor— explicando por que o validador de schema não impede essa escrita. -
Descreva as duas saídas que o Prisma oferece quando o
@uniquedeFichaCatalografica.livroIdé removido, indique qual delas preserva o 1:1 e explique por que a outra passa na validação mesmo mudando o modelo. -
Explique por que
Livro.fichaé obrigatoriamente opcional mesmo que a regra de negócio diga que toda obra tem ficha, e indique onde essa regra passaria a ser garantida. -
Explique por que o auto-relacionamento de
Categoriaexige nome de relação, e compare com o caso de duas relações distintas entre dois models diferentes. -
Escolha a estratégia de
onDeletepara cada relação do modelo da Parte 8, justificando em termos de negócio — inclusive as duas que não a declaram. -
Explique, em termos do princípio da aula 4, por que trocar
autor: StringporautorId: Inté uma mudança incompatível de contrato — e por que ela é aceitável neste ponto do semestre. -
Projete o schema do seu estudo de caso com pelo menos quatro dos seis tipos desta aula. Para cada relação, indique a obrigatoriedade, o
onDeletee os índices que você criaria.
Referências
Principais
- Prisma — Prisma Schema — modelos, campos, atributos e relações
- Prisma — Relações — visão geral dos tipos e do atributo
@relation - Prisma — Relações um-para-muitos — o tipo das partes 2 e 3
- Prisma — Relações um-para-um — a regra do
@uniquee a do lado obrigatoriamente opcional - Prisma — Relações muitos-para-muitos — modo implícito, modo explícito e a tabela de junção
- Prisma — Auto-relacionamentos — por que o nome da relação é obrigatório
Aprofundamento
- Prisma — Ações referenciais — todas as estratégias de
onDeleteeonUpdate, os padrões por tipo de relação e as restrições por banco - Prisma — Prisma Migrate — leitura aprofundada do SQL gerado e do histórico de migrations
- Prisma — Índices e restrições —
@@index,@uniquee@@idcompostos