Soft delete: a coluna que parece boa ideia e complica tudo a seguir
Apagar uma linha é definitivo, e por isso quase ninguém apaga. Acrescenta-se uma coluna: um booleano, um valor por omissão, uma migração de uma linha. A decisão parece barata — e é, naquele dia. A conta vem depois, e vem em cada consulta que se escrever a partir dali.
Este site passou por isto em setembro, e os números ajudam a situar: 213 dos 330 artigos estão escondidos, e nenhum foi apagado. A rigor não é um soft delete — é esconder, não apagar — mas a forma do problema é a mesma, e a fatura também.
1. A migração é a parte fácil
ALTER TABLE "posts" ADD COLUMN "published" BOOLEAN NOT NULL DEFAULT true;É isto. Uma linha, sem tocar num único dado. O que ela cria, e que não se vê em lado nenhum deste script, é uma pré-condição nova em todas as consultas de artigos que existam ou venham a existir. Neste site foram sete funções e nove sítios.
2. O primeiro sítio onde dói: o findUnique deixa de servir
Ler um artigo por id era o caso mais simples que havia:
const artigo = await prisma.post.findUnique({ where: { id } });Com a coluna, isto não se corrige acrescentando uma condição: o findUnique só aceita campos únicos, e published não é um. Tem de mudar de método.
// `findFirst` e não `findUnique`: um artigo escondido responde 404
const artigo = await prisma.post.findFirst({ where: { id, ...PUBLICADOS } });Vale a pena olhar para o que acabou de acontecer: um booleano obrigou a trocar de função. Não é um pormenor do Prisma — é o sintoma de que a chave primária deixou de bastar para identificar uma linha utilizável.
3. As contagens passam a mentir
A lista lateral das etiquetas contava artigos. No dia seguinte prometia trinta, e a página da etiqueta mostrava três. A contagem tem de ser filtrada também:
include: {
_count: { select: { posts: { where: { post: { published: true } } } } },
}E, corrigido isso, apareceu o segundo erro na mesma lista: uma etiqueta cujos artigos estavam todos escondidos continuava lá, agora com um 0 ao lado, a levar a uma página vazia. Faltava deixar de a mostrar.
.filter((t) => t.total > 0)Um booleano, dois erros, na mesma lista — e o segundo só porque o primeiro foi corrigido.
4. O SQL escrito à mão não tem rede
Com um ORM, esquecer a condição dá uma lista errada. Em SQL à mão não dá nada: a consulta corre, não avisa ninguém, e devolve o que não devia.
SELECT p.id, p.title, p.body
FROM posts p
JOIN post_tag_links l ON l.post_id = p.id
WHERE p.published AND p.id <> $1
AND l.tag_id IN (SELECT tag_id FROM post_tag_links WHERE post_id = $1)
GROUP BY p.idAquele p.published no WHERE é toda a diferença entre sugerir artigos e expor os que foram escondidos. Não há erro de tipos que o apanhe, e um teste só o apanha se alguém se tiver lembrado de o escrever — que é metade do argumento para testar contra uma base a sério.
(O $1 não é cosmético: continua a ser SQL parametrizado, pelas razões do artigo da injeção de SQL. Escrever a condição à mão não é desculpa para interpolar o resto.)
5. As duas decisões que reduzem a conta
A primeira é o valor por omissão, e é a que mais vale: DEFAULT true. Um artigo novo nasce publicado, e esconder passa a ser um gesto deliberado. Ao contrário — DEFAULT false — cada inserção esquecida desaparece do site sem ninguém dar por ela, e passa-se a depurar ausências. Depurar o que não aparece é muito pior do que depurar o que aparece a mais.
A segunda é ter a condição escrita num sítio só:
const PUBLICADOS = { published: true } as const;Isto não impede esquecer. O que faz é tornar o esquecimento visível: numa revisão, uma consulta de artigos sem o PUBLICADOS salta à vista — e uma consulta com published: true escrito à mão, repetido em nove sítios, não salta nada.
Acima de um certo tamanho isto deixa de bastar, e a solução passa a ser da base e não da aplicação: uma view que já só contém as linhas visíveis, ou políticas de linha. A aplicação deixa de poder esquecer-se, porque nunca chega a ver o resto.
6. Quando não vale a pena
Há um teste só, e é incómodo: alguém já restaurou alguma? Se a resposta for não, aquilo não é soft delete — é lixo a acumular, com um imposto cobrado em cada consulta e em cada índice. Aqui a resposta é sim, e é o objetivo todo: os 213 voltam com um clique, e foi para isso que a coluna existe em vez de um DELETE.
O resumo
A coluna custa uma linha. A pré-condição custa para sempre. O soft delete não é uma coluna — é uma regra que todas as consultas futuras têm de se lembrar, incluindo as que vão ser escritas por outra pessoa daqui a dois anos, que não assistiu a esta decisão e não tem como adivinhá-la.
Escreva-se onde não se possa esquecer, ou assuma-se desde já que um dia se esquece.