Pular para o conteúdo principal

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 depoisOnde
Consultas, transações e seed sobre este modeloAula 9 — Consultas, transações e seed
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:

  • 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ão null, 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 escrever include: autor na 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á.

O atributo @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:

  1. Quantos de cada lado? Um autor tem muitos livros; um livro tem um autor. Isso é a cardinalidade, e ela decide a forma do schema.
  2. O vínculo pode não existir? Todo livro tem autor; nem todo livro tem editora cadastrada. Isso é a obrigatoriedade, e ela decide o ?.
  3. 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​

Diagrama entidade-relacionamento da biblioteca em notação (mínimo,máximo), com oito entidades. Autor (0,N) escreve Livro (1,1); Editora (0,N) publica Livro (0,1); Livro (0,N) classifica Categoria (0,N); Livro (0,1) detalha FichaCatalografica (1,1); Livro (0,N) tem cópia Exemplar (1,1); Exemplar (0,N) é emprestado Emprestimo (1,1); Leitor (0,N) toma Emprestimo (1,1); e um laço sobre Categoria, em que o papel pai é (0,N) e o papel subcategoria é (0,1). Cada entidade mostra a chave primária sublinhada e as chaves estrangeiras marcadas FK
O acervo inteiro em uma tela: oito entidades e as oito linhas de relacionamento que cobrem os seis tipos tratados nesta aula. As três pontas (0,1) são as únicas relações opcionais do modelo.

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.

TipoOnde aparece na bibliotecaParte
1:N obrigatórioAutor → Livro, Livro → Exemplar2
1:N opcionalEditora → Livro3
N:N implícitoLivro ↔ Categoria4
Junção explícitaExemplar + Leitor → Emprestimo5
1:1Livro → FichaCatalografica6
Auto-relacionamentoCategoria → Categoria7
Sobre o SQL mostrado nesta aula

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égiaEfeito ao apagar o lado 1Quando usar
RestrictA operação falha se houver dependentesO dependente tem valor próprio
CascadeOs dependentes são apagados juntoO dependente não faz sentido sozinho
SetNullA chave estrangeira vira nullO 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ção

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

No seu projeto

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.

Obrigatoriedade e onDelete são perguntas separadas

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

O validador não pega tudo

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.

Quando onDelete não é declarado

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

No seu projeto

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 A e B, e não categoriaId/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.

Comodidade tem prazo

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.

No seu projeto

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.

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.

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:

  1. apagar um Livro tenta apagar seus Exemplar em cascata;
  2. mas cada Exemplar com empréstimo é barrado por Restrict;
  3. 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?

No seu projeto

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.

Onde colocar a chave estrangeira num 1:1

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; titulo e isbn sã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.

No seu projeto

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.

O banco não impede um ciclo

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.

Auto-relacionamento em outros bancos

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.

No seu projeto

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:

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 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:

TipoComo se escreveOnde mora a chave estrangeiraO que garante a cardinalidade
1:N obrigatórioescalar + @relation, sem ?no lado NNOT NULL na coluna
1:N opcionalescalar Int? + relação Model?no lado Ncoluna anulável
N:N implícitosó uma lista de cada ladonuma tabela criada pelo Prismaunicidade do par na tabela de junção
Junção explícitamodel próprio com duas 1:Nnas duas colunas do model do meioa chave primária escolhida
1:1como 1:N, mais @unique na FKno lado acessórioo índice único sobre a FK
Auto-relacionamento1:N opcional apontando para o próprio modelno próprio modelnome 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.

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.

src/livros/dto/criar-livro.dto.ts
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.

Por que as outras relações não entram no contrato agora

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​

ErroSintomaCorreção
? só no escalar ou só no campo de relaçãoO Prisma recusa o schema ao validarOs dois, ou nenhum
@relation(fields:, references:) nos dois ladosErro de validação do schemaSó no lado que guarda a coluna
1:1 sem @unique na chave estrangeiraO 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:1O Prisma recusa o schemaO lado sem FK é sempre opcional
Auto-relacionamento sem nome de relaçãoO Prisma não consegue parear os campos@relation("Nome") idêntico nas duas pontas
SetNull em relação obrigatóriaO schema é aceito pelo validador; a falha só aparece ao apagar o outro ladoSetNull exige coluna anulável
Chave estrangeira sem índiceConsulta lenta só sob volume@@index([campo]) em toda FK usada como filtro
N:N implícito quando a relação tem atributoDescoberto tarde, com dados gravadosTeste do atributo antes de escolher
Uniformizar onDelete no modelo inteiroPerda de dados ou exclusão travada, conforme o casoUma 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.

Como aproveitar cada passo

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:

  1. a coluna "autorId" INTEGER NOT NULL em Livro;
  2. as duas cláusulas ON DELETE — uma RESTRICT, uma CASCADE. 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:

  1. como ela se chama, e de onde veio esse nome;
  2. como se chamam as duas colunas;
  3. 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.

  1. Remova o ? de Livro.ficha e rode npx prisma validate: o Prisma explica que o lado sem chave estrangeira não pode ser obrigatório.
  2. Recoloque o ? e remova o @unique de livroId. 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:

  1. as oito entidades aparecem, e todas estão vazias;
  2. Livro tem autorId (obrigatório) e editoraId (aceita vazio);
  3. FichaCatalografica tem livroId com restrição de unicidade;
  4. Categoria tem paiId, 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​

  1. 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 onDelete em 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.
  2. Converta Livro–Categoria de N:N implícito para junção explícita, com um model LivroCategoria que tenha atribuidoEm e chave primária composta. Gere a migration e compare o SQL das duas formas, lado a lado.
  3. Reescreva FichaCatalografica usando a chave estrangeira como chave primária (livroId Int @id, sem id próprio). Gere a migration, diga o que muda no SQL e o que se perde com essa escolha.
  4. 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 declaram onDelete. 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 status as lista todas como aplicadas;
  • o SQL de cada migration foi lido, e foram localizados: a coluna NOT NULL do 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 onDelete aparecem 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;
  • CriarLivroDto usa autorId obrigatório e editoraId opcional (@IsOptional());
  • o filtro de listar atravessa a relação com autor.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)​

  1. Explique por que a lista Autor.livros não corresponde a nenhuma coluna da tabela Autor, e descreva onde a informação dessa relação está de fato guardada.

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

  3. 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?

  4. Justifique por que SetNull só faz sentido em relação opcional e descreva em que momento o problema apareceria se você o aplicasse a Livro.autor — explicando por que o validador de schema não impede essa escrita.

  5. Descreva as duas saídas que o Prisma oferece quando o @unique de FichaCatalografica.livroId é removido, indique qual delas preserva o 1:1 e explique por que a outra passa na validação mesmo mudando o modelo.

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

  7. Explique por que o auto-relacionamento de Categoria exige nome de relação, e compare com o caso de duas relações distintas entre dois models diferentes.

  8. Escolha a estratégia de onDelete para cada relação do modelo da Parte 8, justificando em termos de negócio — inclusive as duas que não a declaram.

  9. Explique, em termos do princípio da aula 4, por que trocar autor: String por autorId: Int é uma mudança incompatível de contrato — e por que ela é aceitável neste ponto do semestre.

  10. 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 onDelete e os índices que você criaria.


Referências​

Principais​

Aprofundamento​