Busca de texto¶
Duas camadas, porque respondem perguntas diferentes.
Camada 1: contains — substring escapada, igual em todo banco¶
import { contains, select } from "tempest-db-js";
session.execute(select(User).where(contains(["name", "email"], termo)));
- O termo é tokenizado:
"ana silva"exigeanaesilva, cada um podendo aparecer em qualquer das colunas. Digitar mais palavras estreita o resultado, que é o que uma caixa de busca precisa. - Cada token é escapado. Sem índice, sem extensão, sem migração.
{ match: "any" }troca o "todos" por "qualquer um".
ilike cru transforma 100% em "tudo"
% e _ são curingas do LIKE. Passar o que o usuário digitou direto para
{ ilike: termo } faz 100% casar com toda linha da tabela:
where({ title: { ilike: "100%" } }) // ❌ casa tudo
where({ title: { iContains: "100%" } }) // ✅ casa "100%" literal
where(contains(["title"], "100%")) // ✅ idem, em várias colunas
O operador iContains recebe texto, não padrão: ele escapa e monta o
%…% sozinho. Para montar um padrão à mão, escape com escapeLike(termo).
O ESCAPE '\' não é decoração
O PostgreSQL já trata \ como caractere de escape por padrão; o SQLite não tem
nenhum até alguém declarar. Sem a cláusula ESCAPE, o escape feito do nosso lado
não significaria nada lá — por isso ela é sempre emitida.
Camada 2: fullText — stemming e ranking no PostgreSQL¶
const termo = "comprar";
session.execute(
select(Post)
.where(fullText(["title", "body"], termo, { language: "portuguese" }))
.orderBy(fullTextRank(["title", "body"], termo, { language: "portuguese" }), "desc"),
);
No PostgreSQL vira to_tsvector(...) @@ websearch_to_tsquery(...): stemming
(comprar acha "comprou"), remoção de stop word, e a sintaxe de aspas e -exclusão
que o usuário já conhece de buscador. fullTextRank vira ts_rank.
Fora do PostgreSQL, cai para a camada 1 — de propósito
No SQLite (e no MySQL) fullText compila como contains: as linhas certas, sem
stemming. Um banco de desenvolvimento continua funcionando, e a diferença é
qualidade de ranking, não corretude.
fullTextRank vira uma constante nesses bancos, então ordenar por ele é inócuo — o
ranking é a parte que realmente não existe sem motor de busca.
coalesce em toda coluna
O documento é coalesce(col, '') concatenado. Em SQL, um NULL no meio de uma
concatenação faz o documento inteiro virar NULL — uma coluna vazia excluiria a
linha da busca sem avisar.
Índice¶
fullText sem índice funciona, mas varre a tabela. Em produção, crie o índice GIN
sobre a mesma expressão:
CREATE INDEX ix_posts_fts ON posts
USING GIN (to_tsvector('portuguese', coalesce(title, '') || ' ' || coalesce(body, '')));
Recapitulando¶
- Caixa de busca ⇒
contains(ou o operadoriContains). - Precisa de stemming e ranking no PostgreSQL ⇒
fullText+fullTextRank. escapeLikepara quem monta padrão à mão.- Fora do PostgreSQL, o full-text degrada para substring — documentado, não silencioso. 🚀