O problema que o LIKE não resolve#
Você tem uma tela com um grid de clientes. O usuário começa a digitar “João Si” e espera ver “João Silva”, “João Silveira”, “João Simões”. Rápido. Enquanto digita.
A solução mais imediata é usar LIKE:
1
| SELECT * FROM Clientes WHERE Nome LIKE '%João Si%'
|
Funciona. Para 100 registros. Para 500 mil registros em produção, com vários usuários digitando ao mesmo tempo, esse LIKE '%...%' é um full table scan — o banco lê cada linha da tabela, uma a uma, sem usar índice. A query fica lenta, o banco sofre, o usuário abandona a tela.
O Full-Text Search (FTS) resolve exatamente isso: um mecanismo de indexação e busca textual especialmente projetado para consultas em texto, com suporte a:
- Busca por palavras e frases exatas
- Relevância e ranking de resultados
- Radicais de palavras (stemming): “rodando” encontra “rodar”
- Sinônimos e stopwords
- Operadores lógicos: AND, OR, NOT, NEAR
- Índices invertidos — o banco não lê cada linha, lê o índice
Neste artigo vamos implementar o cenário completo: um frontend Angular com busca reativa paginada que chama uma API REST C# que usa Full-Text Search no banco de dados — com exemplos para SQL Server, PostgreSQL e Oracle.
📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Veja também os exemplos Angular (search-ui) e os scripts SQL.
O cenário: Angular + API REST + Banco via FTS#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| ┌─────────────────────────────────────────────────────────────┐
│ Angular Grid (frontend) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Digite o nome do cliente... │ │
│ └─────────────────────────────────────────────┘ │
│ ↓ debounce 400ms + mínimo 3 caracteres │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Nome | CPF | Email │ │
│ │ João Silva | 123.456.789 | [email protected] │ │
│ │ João Silveira | 987.654.321 | [email protected] │ │
│ └──────────────────────────────────────────────────────┘ │
│ Página 1 de 12 [< Anterior] [Próxima >] │
└─────────────────────────────────────────────────────────────┘
│ GET /api/clientes?q=João+Si&pagina=1&tamanho=20
▼
┌─────────────────────────────────────────────────────────────┐
│ ASP.NET Core API │
│ ClientesController → ClienteService → IClienteRepository │
└─────────────────────────────────────────────────────────────┘
│ Full-Text Search query paginada
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SQL Server │ │ PostgreSQL │ │ Oracle │
│ CONTAINS() │ │ to_tsquery │ │ CONTAINS() │
│ FT Index │ │ GIN Index │ │ CTX Domain │
└──────────────┘ └──────────────┘ └──────────────┘
|
Por que SPA?
O padrão de “busca durante digitação” é nativo em frameworks SPA (Single-Page Applications) como Angular, React e Vue. Nesses frameworks, toda a lógica de interação roda no navegador, e o modelo de programação reativa — com RxJS no Angular — fornece os operadores debounceTime, distinctUntilChanged e switchMap diretamente, sem gambiarras.
Em frameworks server-side (Razor Pages, Blazor Server, Thymeleaf, JSF…), o fluxo de renderização é diferente: cada interação precisa ir ao servidor e retornar HTML atualizado. Para implementar o mesmo comportamento, você depende de JavaScript adicional (seja escrito à mão, seja via HTMX, Turbo, Alpine.js, etc.) para capturar o evento de digitação, aplicar o debounce no cliente e disparar a requisição assíncrona — adicionando uma camada de complexidade que não existe no modelo SPA. O resultado funciona, mas o desenvolvimento é mais trabalhoso e a manutenção mais fragmentada (lógica dividida entre o servidor e scripts JS avulsos).
Se o seu projeto já usa um framework server-side, avalie se vale introduzir uma ilha de interatividade SPA (Angular Element, React isolado) apenas para esse componente de busca, ou se um script JS leve como HTMX é suficiente para o caso de uso.
O Angular com RxJS torna elegante o padrão de “pesquisa durante digitação”. A ideia é simples: não disparar uma requisição a cada tecla pressionada — isso sobrecarregaria o servidor. Usamos dois filtros:
- Mínimo de 3 caracteres — evitar buscas com texto muito curto
- Debounce de 400ms — aguardar o usuário parar de digitar antes de enviar
Componente de busca#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| // clientes-grid.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap,
takeUntil
} from 'rxjs/operators';
import { ClienteService } from './cliente.service';
import { PagedResult, ClienteDto } from './models';
@Component({
selector: 'app-clientes-grid',
templateUrl: './clientes-grid.component.html'
})
export class ClientesGridComponent implements OnInit, OnDestroy {
buscaControl = new FormControl('');
clientes: ClienteDto[] = [];
paginaAtual = 1;
totalPaginas = 1;
totalRegistros = 0;
carregando = false;
termoBusca = '';
private destroy$ = new Subject<void>();
constructor(private clienteService: ClienteService) {}
ngOnInit(): void {
this.buscaControl.valueChanges.pipe(
debounceTime(400), // aguarda 400ms após a última tecla
distinctUntilChanged(), // ignora se o valor não mudou
filter(termo => !termo || termo.length === 0 || termo.length >= 3),
takeUntil(this.destroy$)
).subscribe(termo => {
this.termoBusca = termo ?? '';
this.paginaAtual = 1; // volta para a primeira página a cada nova busca
this.buscar();
});
// Carrega a primeira página sem filtro ao abrir a tela
this.buscar();
}
buscar(): void {
this.carregando = true;
this.clienteService
.listar(this.termoBusca, this.paginaAtual, 20)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (resultado) => {
this.clientes = resultado.itens;
this.totalPaginas = resultado.totalPaginas;
this.totalRegistros = resultado.totalRegistros;
this.carregando = false;
},
error: () => {
this.carregando = false;
}
});
}
irParaPagina(pagina: number): void {
if (pagina < 1 || pagina > this.totalPaginas) return;
this.paginaAtual = pagina;
this.buscar();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
|
Template HTML#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| <!-- clientes-grid.component.html -->
<div class="grid-container">
<div class="search-bar">
<input
[formControl]="buscaControl"
placeholder="🔍 Digite o nome do cliente..."
class="input-busca"
/>
<span *ngIf="carregando" class="spinner">Buscando...</span>
</div>
<table class="grid-tabela">
<thead>
<tr>
<th>Nome</th>
<th>CPF/CNPJ</th>
<th>E-mail</th>
<th>Cidade</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let cliente of clientes">
<td>{{ cliente.nome }}</td>
<td>{{ cliente.cpfCnpj }}</td>
<td>{{ cliente.email }}</td>
<td>{{ cliente.cidade }}</td>
</tr>
<tr *ngIf="!carregando && clientes.length === 0">
<td colspan="4" class="sem-resultado">Nenhum cliente encontrado.</td>
</tr>
</tbody>
</table>
<div class="paginacao">
<span>{{ totalRegistros }} registros | Página {{ paginaAtual }} de {{ totalPaginas }}</span>
<button (click)="irParaPagina(paginaAtual - 1)" [disabled]="paginaAtual <= 1">‹ Anterior</button>
<button (click)="irParaPagina(paginaAtual + 1)" [disabled]="paginaAtual >= totalPaginas">Próxima ›</button>
</div>
</div>
|
Service Angular#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // cliente.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { PagedResult, ClienteDto } from './models';
@Injectable({ providedIn: 'root' })
export class ClienteService {
private readonly apiUrl = '/api/clientes';
constructor(private http: HttpClient) {}
listar(
q: string,
pagina: number,
tamanho: number
): Observable<PagedResult<ClienteDto>> {
let params = new HttpParams()
.set('pagina', pagina)
.set('tamanho', tamanho);
if (q?.trim()) {
params = params.set('q', q.trim());
}
return this.http.get<PagedResult<ClienteDto>>(this.apiUrl, { params });
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // models.ts
export interface ClienteDto {
id: number;
nome: string;
cpfCnpj: string;
email: string;
cidade: string;
}
export interface PagedResult<T> {
itens: T[];
totalRegistros: number;
totalPaginas: number;
paginaAtual: number;
tamanhoPagina: number;
}
|
Por que switchMap? O switchMap cancela a requisição anterior quando uma nova chega. Se o usuário digitar rapidamente e o servidor ainda não respondeu a primeira requisição, a segunda cancela a primeira — sem risco de resultados chegando fora de ordem.
Controller#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // ClientesController.cs
[ApiController]
[Route("api/clientes")]
public class ClientesController(IClienteService service) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<ClienteDto>>> Listar(
[FromQuery] string? q,
[FromQuery] int pagina = 1,
[FromQuery] int tamanho = 20,
CancellationToken ct = default)
{
if (tamanho > 100) tamanho = 100; // limite máximo por segurança
if (pagina < 1) pagina = 1;
var resultado = await service.ListarAsync(q, pagina, tamanho, ct);
return Ok(resultado);
}
}
|
Modelos e DTOs#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // PagedResult.cs
public record PagedResult<T>(
IReadOnlyList<T> Itens,
int TotalRegistros,
int TotalPaginas,
int PaginaAtual,
int TamanhoPagina);
// ClienteDto.cs
public record ClienteDto(
int Id,
string Nome,
string CpfCnpj,
string Email,
string Cidade);
// Cliente.cs (entidade)
public class Cliente
{
public int Id { get; set; }
public string Nome { get; set; } = "";
public string CpfCnpj { get; set; } = "";
public string Email { get; set; } = "";
public string Cidade { get; set; } = "";
}
|
Service#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // ClienteService.cs
public class ClienteService(IClienteRepository repo) : IClienteService
{
public async Task<PagedResult<ClienteDto>> ListarAsync(
string? q, int pagina, int tamanho, CancellationToken ct)
{
var (itens, total) = await repo.BuscarAsync(q, pagina, tamanho, ct);
return new PagedResult<ClienteDto>(
Itens: itens.Select(c => new ClienteDto(c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade)).ToList(),
TotalRegistros: total,
TotalPaginas: (int)Math.Ceiling((double)total / tamanho),
PaginaAtual: pagina,
TamanhoPagina: tamanho);
}
}
|
SQL Server: Full-Text Search#
O que é o Full-Text Index no SQL Server#
O SQL Server mantém um catálogo de texto completo — uma estrutura de índice invertido separada dos índices B-Tree normais. Ele tokeniza o texto das colunas indexadas, aplica stopwords (palavras ignoradas: “de”, “do”, “a”…) e stemming (redutor de radicais) por idioma, e armazena os tokens com suas posições nos documentos.
A busca usa os predicados CONTAINS e FREETEXT:
CONTAINS(coluna, '"João Silva"') — busca literal ou com prefixoCONTAINS(coluna, '"João*"') — prefixo (João, Joãozinho, etc.)FREETEXT(coluna, 'João Silva') — linguagem natural, mais tolerante
Pré-requisito: habilitando o Full-Text Search no SQL Server#
O Full-Text Search é um recurso opcional do SQL Server — ele precisa estar instalado na instância antes de qualquer CREATE FULLTEXT INDEX. Em instalações padrão do SQL Server Developer, Express com Advanced Services ou Enterprise ele já vem ativo; em instalações mínimas pode estar ausente.
Verifique se está ativo:
1
2
| -- Retorna 1 se o recurso está instalado, 0 se não está
SELECT FULLTEXTSERVICEPROPERTY('IsFullTextInstalled') AS FTInstalled;
|
Se retornar 0, você precisa adicionar o feature via SQL Server Setup:
- Abra o SQL Server Installation Center (setup.exe da mídia de instalação)
- Escolha “Add features to an existing instance”
- Selecione “Full-Text and Semantic Extractions for Search”
- Conclua o assistente — não é necessário reiniciar a instância
Além disso, o serviço auxiliar SQL Full-text Filter Daemon Launcher (MSSQLFDLauncher) precisa estar em execução. Verifique no SQL Server Configuration Manager ou via PowerShell:
1
| Get-Service -Name 'MSSQLFDLauncher*'
|
Referência completa: Introdução ao Full-Text Search — Microsoft Learn
Setup do Full-Text Index#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| -- 1. Criar o catálogo (uma vez por banco)
CREATE FULLTEXT CATALOG catalogo_clientes AS DEFAULT;
-- 2. Criar o índice FT na tabela Clientes
-- Requer que a tabela tenha uma coluna chave única (geralmente PK)
CREATE FULLTEXT INDEX ON Clientes
(
Nome LANGUAGE 'Brazilian',
Email LANGUAGE 'Brazilian',
Cidade LANGUAGE 'Brazilian'
)
KEY INDEX PK_Clientes
ON catalogo_clientes
WITH STOPLIST = SYSTEM;
-- 3. Verificar progresso da indexação (pode demorar na primeira vez)
SELECT name, is_enabled, crawl_status_description
FROM sys.fulltext_indexes fi
JOIN sys.tables t ON fi.object_id = t.object_id;
|
Repositório — SQL Server#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
| // ClienteRepositorioSqlServer.cs
public class ClienteRepositorioSqlServer(AppDbContext db) : IClienteRepository
{
public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
string? q, int pagina, int tamanho, CancellationToken ct)
{
// Sem filtro: paginação simples
if (string.IsNullOrWhiteSpace(q))
return await BuscarSemFiltroAsync(pagina, tamanho, ct);
var termo = FormatarTermoSqlServer(q);
var offset = (pagina - 1) * tamanho;
// CONTAINS com prefixo — suporta busca parcial no início das palavras
var sql = """
SELECT Id, Nome, CpfCnpj, Email, Cidade
FROM Clientes
WHERE CONTAINS((Nome, Email, Cidade), {0})
ORDER BY Nome
OFFSET {1} ROWS FETCH NEXT {2} ROWS ONLY
""";
var countSql = """
SELECT COUNT(*)
FROM Clientes
WHERE CONTAINS((Nome, Email, Cidade), {0})
""";
var itens = await db.Clientes
.FromSqlRaw(sql, termo, offset, tamanho)
.AsNoTracking()
.ToListAsync(ct);
var total = await db.Database
.SqlQueryRaw<int>(countSql, termo)
.FirstAsync(ct);
return (itens, total);
}
private static string FormatarTermoSqlServer(string q)
{
// Divide em palavras e usa prefixo "*" para busca parcial:
// "João Si" → '"João*" AND "Si*"'
var palavras = q.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(p => $'"{EscaparSqlServer(p)}*"');
return string.Join(" AND ", palavras);
}
private static string EscaparSqlServer(string palavra)
// Previne injeção no predicado CONTAINS
=> palavra.Replace("\"", "\"\"").Replace("'", "''");
private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
int pagina, int tamanho, CancellationToken ct)
{
var offset = (pagina - 1) * tamanho;
var itens = await db.Clientes
.AsNoTracking()
.OrderBy(c => c.Nome)
.Skip(offset)
.Take(tamanho)
.ToListAsync(ct);
var total = await db.Clientes.CountAsync(ct);
return (itens, total);
}
}
|
Exemplo de resultado#
Para o termo “João Si”, o SQL Server executa internamente:
1
2
3
4
5
6
7
8
9
| -- Equivalente ao que o FT engine executa:
SELECT Id, Nome, CpfCnpj, Email, Cidade
FROM Clientes
WHERE CONTAINS((Nome, Email, Cidade), '"João*" AND "Si*"')
ORDER BY Nome
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
-- Retorna: João Silva, João Silveira, João Simões, João Simplício...
-- Usando o índice invertido — não faz full table scan
|
Quando a ordem por relevância importa, use CONTAINSTABLE ou FREETEXTTABLE que retornam uma coluna RANK:
1
2
3
4
5
6
7
8
| var rankSql = """
SELECT c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade
FROM Clientes c
INNER JOIN CONTAINSTABLE(Clientes, (Nome, Email, Cidade), {0}) AS ft
ON c.Id = ft.[KEY]
ORDER BY ft.[RANK] DESC, c.Nome
OFFSET {1} ROWS FETCH NEXT {2} ROWS ONLY
""";
|
PostgreSQL: Full-Text Search com tsvector#
Como funciona o FTS no PostgreSQL#
O PostgreSQL tem suporte nativo a FTS sem extensões adicionais. A abordagem usa:
tsvector — representação normalizada do texto indexado (tokens + posições)tsquery — expressão de busca compiladato_tsvector(idioma, texto) — converte texto para tsvectorto_tsquery(idioma, query) — converte busca para tsquerywebsearch_to_tsquery — mais tolerante, aceita entrada do usuário diretamente- Índice GIN — índice invertido eficiente para tsvector
Pré-requisito: habilitando FTS no PostgreSQL#
O PostgreSQL inclui suporte nativo a Full-Text Search desde a versão 8.3 — não há instalação de componente adicional nem licença separada. O recurso está disponível em qualquer instalação padrão, inclusive RDS, Azure Database for PostgreSQL e Cloud SQL.
Verifique quais configurações de idioma estão disponíveis:
1
2
3
4
5
| -- Lista os dicionários de busca textual instalados
SELECT cfgname, cfgparser::regproc
FROM pg_ts_config
ORDER BY cfgname;
-- Deve conter 'portuguese' para uso em português
|
Para texto em português com acentos, é recomendável habilitar a extensão unaccent, que normaliza caracteres acentuados durante a indexação (“João” e “Joao” passam a ser equivalentes):
1
2
3
4
5
6
7
8
9
10
| -- Extensão incluída no PostgreSQL contrib — sem dependência externa
CREATE EXTENSION IF NOT EXISTS unaccent;
-- Variante do dicionário portuguese que ignora acentos:
CREATE TEXT SEARCH CONFIGURATION portuguese_unaccent (COPY = portuguese);
ALTER TEXT SEARCH CONFIGURATION portuguese_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, portuguese_stem;
-- Use 'portuguese_unaccent' no lugar de 'portuguese' nas funções to_tsvector / to_tsquery
|
Referência completa: Full-Text Search — PostgreSQL Docs
Setup do índice FTS#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| -- 1. Adicionar coluna tsvector para indexação (alternativa: calcular on-the-fly)
ALTER TABLE clientes ADD COLUMN busca_fts TSVECTOR;
-- 2. Popular a coluna com os textos concatenados (pesos diferentes por campo)
UPDATE clientes SET busca_fts =
setweight(to_tsvector('portuguese', coalesce(nome, '')), 'A') ||
setweight(to_tsvector('portuguese', coalesce(email, '')), 'B') ||
setweight(to_tsvector('portuguese', coalesce(cidade, '')), 'C');
-- 3. Manter a coluna atualizada automaticamente via trigger
CREATE OR REPLACE FUNCTION atualizar_busca_fts()
RETURNS TRIGGER AS $$
BEGIN
NEW.busca_fts :=
setweight(to_tsvector('portuguese', coalesce(NEW.nome, '')), 'A') ||
setweight(to_tsvector('portuguese', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('portuguese', coalesce(NEW.cidade, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tg_clientes_busca_fts
BEFORE INSERT OR UPDATE ON clientes
FOR EACH ROW EXECUTE FUNCTION atualizar_busca_fts();
-- 4. Criar índice GIN (otimizado para tsvector)
CREATE INDEX idx_clientes_busca_fts ON clientes USING GIN (busca_fts);
|
setweight e os pesos A, B, C, D: permitem dar prioridade a campos. Resultados onde o termo aparece no nome (A) recebem ranking mais alto do que onde aparece na cidade (C). O ranking é calculado pela função ts_rank.
Repositório — PostgreSQL#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| // ClienteRepositorioPostgres.cs
public class ClienteRepositorioPostgres(AppDbContext db) : IClienteRepository
{
public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
string? q, int pagina, int tamanho, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q))
return await BuscarSemFiltroAsync(pagina, tamanho, ct);
var offset = (pagina - 1) * tamanho;
// websearch_to_tsquery: aceita entrada humana diretamente — mais seguro
// Converte "João Si" em 'João:* & Si:*' internamente
// A cláusula :* indica prefixo (busca parcial)
var sql = """
SELECT id, nome, cpf_cnpj, email, cidade
FROM clientes
WHERE busca_fts @@ websearch_to_tsquery('portuguese', {0})
ORDER BY ts_rank(busca_fts, websearch_to_tsquery('portuguese', {0})) DESC,
nome
LIMIT {1} OFFSET {2}
""";
var countSql = """
SELECT COUNT(*)
FROM clientes
WHERE busca_fts @@ websearch_to_tsquery('portuguese', {0})
""";
var itens = await db.Clientes
.FromSqlRaw(sql, q.Trim(), tamanho, offset)
.AsNoTracking()
.ToListAsync(ct);
var total = await db.Database
.SqlQueryRaw<int>(countSql, q.Trim())
.FirstAsync(ct);
return (itens, total);
}
private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
int pagina, int tamanho, CancellationToken ct)
{
var offset = (pagina - 1) * tamanho;
var itens = await db.Clientes
.AsNoTracking()
.OrderBy(c => c.Nome)
.Skip(offset)
.Take(tamanho)
.ToListAsync(ct);
var total = await db.Clientes.CountAsync(ct);
return (itens, total);
}
}
|
Busca com prefixo no PostgreSQL#
websearch_to_tsquery não suporta prefixo nativamente. Para busca durante digitação onde cada palavra deve ser tratada como prefixo, use to_tsquery com :*:
1
2
3
4
5
6
7
8
9
10
11
12
| private static string FormatarTermoPostgres(string q)
{
// "João Si" → "'João':* & 'Si':*"
var palavras = q.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(p => $"'{EscaparPostgres(p)}':*");
return string.Join(" & ", palavras);
}
private static string EscaparPostgres(string p)
=> p.Replace("'", "''").Replace("\\", "\\\\");
|
1
2
3
4
5
6
7
8
9
10
11
| // Uso com to_tsquery para prefixo:
var sql = """
SELECT id, nome, cpf_cnpj, email, cidade
FROM clientes
WHERE busca_fts @@ to_tsquery('portuguese', {0})
ORDER BY ts_rank(busca_fts, to_tsquery('portuguese', {0})) DESC, nome
LIMIT {1} OFFSET {2}
""";
// Passa a query formatada:
var termo = FormatarTermoPostgres(q);
|
O provider Npgsql para PostgreSQL oferece suporte nativo a Full-Text Search diretamente no LINQ, sem precisar de FromSqlRaw. Para queries mais simples — sem ranking customizado ou prefixo — essa abordagem é mais limpa e mantém a tipagem forte:
1
2
3
4
5
6
7
8
9
10
| // Busca FTS via LINQ puro — sem SQL raw
// Requer: Npgsql.EntityFrameworkCore.PostgreSQL
var itens = await db.Clientes
.Where(c => EF.Functions.ToTsVector("portuguese", c.Nome + " " + c.Email + " " + c.Cidade)
.Matches(EF.Functions.WebSearchToTsQuery("portuguese", q)))
.OrderBy(c => c.Nome)
.Skip(offset)
.Take(tamanho)
.AsNoTracking()
.ToListAsync(ct);
|
⚠️ Atenção: A abordagem via LINQ com EF.Functions funciona bem para buscas simples, mas não suporta prefixo (:* / busca enquanto digita) e oferece menos controle sobre o ranking. Para busca reativa com autocomplete — como o cenário deste artigo — o FromSqlRaw com to_tsquery e :* continua sendo a melhor escolha. Para entender como o EF Core abstrai o acesso a dados e quando usar LINQ vs SQL raw, veja EF Core 8 com Fluent API: Mapeamento e Desacoplamento Total.
Oracle: Full-Text Search com Oracle Text (CTX)#
Como funciona o Oracle Text#
O Oracle usa o Oracle Text (também chamado de Oracle Context ou CTX) para Full-Text Search. Os índices são criados com CTXSYS.CONTEXT e as buscas usam o predicado CONTAINS(), que retorna um score de relevância:
CONTAINS(coluna, 'João') — busca simplesCONTAINS(coluna, 'João%') — prefixo (wildcard direito)CONTAINS(coluna, 'João AND Silva') — operador ANDCONTAINS(coluna, 'FUZZY(Joao, 60, 6, N)') — busca fuzzy (tolera erros ortográficos)
Pré-requisito: habilitando o Oracle Text#
O Oracle Text (anteriormente chamado de Oracle ConText Cartridge ou InterMedia Text) é incluído em todas as edições do Oracle Database — Standard Edition 2, Enterprise Edition e Oracle Database Free (XE). Ele precisa estar instalado no schema CTXSYS e o usuário da aplicação precisa ter os privilégios corretos.
Verifique se o componente está instalado:
1
2
3
4
5
| -- Requer acesso a DBA_REGISTRY (DBA ou SELECT_CATALOG_ROLE)
SELECT comp_name, version, status
FROM dba_registry
WHERE comp_name = 'Oracle Text';
-- STATUS deve ser 'VALID'; se ausente, o componente não está instalado
|
Se não estiver instalado, execute como SYS AS SYSDBA:
1
2
3
4
5
6
7
8
9
| -- Instala o Oracle Text no banco (executado uma única vez)
-- ctxsys = usuário dono do Oracle Text
-- SYSAUX = tablespace padrão para o schema CTXSYS
-- TEMP = tablespace temporária
-- NOLOCK = não bloqueia a conta CTXSYS após a instalação
@?/ctx/admin/catctx.sql ctxsys SYSAUX TEMP NOLOCK
-- Em seguida, compile objetos inválidos (se necessário)
@?/rdbms/admin/utlrp.sql
|
Conceda os privilégios necessários ao usuário da aplicação:
1
2
3
4
5
6
| -- Opção 1: role CTXAPP (cobre CREATE INDEX, CTX_DDL, CTX_QUERY)
GRANT CTXAPP TO meu_usuario;
-- Opção 2: privilégios granulares (produção com princípio de menor privilégio)
GRANT EXECUTE ON CTXSYS.CTX_DDL TO meu_usuario;
GRANT EXECUTE ON CTXSYS.CTX_QUERY TO meu_usuario;
|
Verifique que o serviço de indexação Oracle Text está ativo:
1
2
| -- O processo extproc / extjob é necessário para o sincronizador de índices
SELECT program, status FROM v$process WHERE program LIKE '%extproc%';
|
Referência completa: Oracle Text Application Developer’s Guide · Oracle Text Reference
Setup do índice CTX#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| -- 1. Criar preferências de idioma (uma vez)
BEGIN
CTX_DDL.CREATE_PREFERENCE('pref_clientes', 'BASIC_LEXER');
CTX_DDL.SET_ATTRIBUTE('pref_clientes', 'BASE_LETTER', 'YES'); -- ignora acentos
CTX_DDL.SET_ATTRIBUTE('pref_clientes', 'MIXED_CASE', 'NO'); -- case insensitive
CTX_DDL.CREATE_PREFERENCE('wordlist_clientes', 'BASIC_WORDLIST');
CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_INDEX', 'YES'); -- busca por prefixo
CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_MIN_LENGTH', '2');
CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_MAX_LENGTH', '10');
END;
/
-- 2. Criar índice CONTEXT na coluna Nome
-- Para múltiplas colunas, crie uma coluna calculada ou use MULTI_COLUMN_DATASTORE
CREATE INDEX idx_clientes_fts ON clientes(nome)
INDEXTYPE IS CTXSYS.CONTEXT
PARAMETERS ('LEXER pref_clientes WORDLIST wordlist_clientes');
-- 3. Sincronizar o índice após inserções/updates (pode ser agendado)
EXEC CTX_DDL.SYNC_INDEX('idx_clientes_fts');
-- Para auto-sincronização (Oracle 12c+):
-- PARAMETERS ('... SYNC (ON COMMIT)')
|
Repositório — Oracle#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| // ClienteRepositorioOracle.cs
public class ClienteRepositorioOracle(AppDbContext db) : IClienteRepository
{
public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
string? q, int pagina, int tamanho, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q))
return await BuscarSemFiltroAsync(pagina, tamanho, ct);
var termo = FormatarTermoOracle(q);
var offset = (pagina - 1) * tamanho;
// CONTAINS retorna score 0-100; > 0 significa que encontrou
// SCORE(1) é o score da primeira chamada CONTAINS na query
var sql = $"""
SELECT Id, Nome, CpfCnpj, Email, Cidade
FROM (
SELECT c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade,
SCORE(1) AS relevancia,
ROW_NUMBER() OVER (ORDER BY SCORE(1) DESC, c.Nome) AS rn
FROM Clientes c
WHERE CONTAINS(c.Nome, :termo, 1) > 0
)
WHERE rn > :offset AND rn <= :limite
ORDER BY rn
""";
var countSql = """
SELECT COUNT(*)
FROM Clientes
WHERE CONTAINS(Nome, :termo, 1) > 0
""";
var itens = await db.Clientes
.FromSqlRaw(sql, new OracleParameter("termo", termo),
new OracleParameter("offset", offset),
new OracleParameter("limite", offset + tamanho))
.AsNoTracking()
.ToListAsync(ct);
var total = await db.Database
.SqlQueryRaw<int>(countSql, new OracleParameter("termo", termo))
.FirstAsync(ct);
return (itens, total);
}
private static string FormatarTermoOracle(string q)
{
// "João Si" → "João% AND Si%"
// O % no Oracle Text é wildcard de prefixo (similar ao LIKE 'Si%')
var palavras = q.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(p => $"{EscaparOracle(p)}%");
return string.Join(" AND ", palavras);
}
private static string EscaparOracle(string p)
=> p.Replace("'", "''").Replace("%", "\\%").Replace("_", "\\_");
private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
int pagina, int tamanho, CancellationToken ct)
{
var offset = (pagina - 1) * tamanho;
var itens = await db.Clientes
.AsNoTracking()
.OrderBy(c => c.Nome)
.Skip(offset)
.Take(tamanho)
.ToListAsync(ct);
var total = await db.Clientes.CountAsync(ct);
return (itens, total);
}
}
|
Registrando os repositórios por banco#
No Program.cs, selecione o repositório conforme o banco configurado:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // Program.cs
var banco = builder.Configuration["DatabaseProvider"]; // "sqlserver" | "postgres" | "oracle"
builder.Services.AddScoped<IClienteService, ClienteService>();
builder.Services.AddScoped<IClienteRepository>(sp =>
{
var db = sp.GetRequiredService<AppDbContext>();
return banco switch
{
"postgres" => new ClienteRepositorioPostgres(db),
"oracle" => new ClienteRepositorioOracle(db),
_ => new ClienteRepositorioSqlServer(db) // default: SQL Server
};
});
// DbContext — seleciona o provider
builder.Services.AddDbContext<AppDbContext>(opt =>
{
if (banco == "postgres")
opt.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"));
else if (banco == "oracle")
opt.UseOracle(builder.Configuration.GetConnectionString("Oracle"));
else
opt.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"));
});
|
Comparativo entre os três bancos#
| Característica | SQL Server | PostgreSQL | Oracle |
|---|
| Mecanismo | Full-Text Catalog + FT Index | tsvector + GIN Index | Oracle Text (CTX) |
| Predicado de busca | CONTAINS(), FREETEXT() | @@ (operador tsvector/tsquery) | CONTAINS() |
| Busca por prefixo | "termo*" | 'termo':* | termo% |
| Ranking nativo | CONTAINSTABLE RANK | ts_rank() | SCORE() |
| Stemming | Por idioma (LANGUAGE) | Por idioma (dictionary) | Por lexer |
| Acentuação | Depende de collation | unaccent extension | BASE_LETTER = YES |
| Update do índice | Automático (background) | Trigger na coluna | Manual/SYNC ou ON COMMIT |
| Setup | CREATE FULLTEXT INDEX | CREATE INDEX ... USING GIN | CTX_DDL.CREATE_PREFERENCE + CREATE INDEX INDEXTYPE CTXSYS.CONTEXT |
| Licença necessária | Incluída no SQL Server | Nativa, sem custo extra | Oracle Text (incluído) |
Boas Práticas e Cuidados#
1. Proteja contra injeção no predicado FTS#
Os predicados CONTAINS e to_tsquery não são imunes a injeção — eles têm sua própria sintaxe que pode ser manipulada. Sempre:
- Use parâmetros (
{0}, :param) quando o provider suportar - Escape os caracteres especiais antes de interpoler strings
- Limite o tamanho máximo do termo de busca
1
2
3
4
5
6
| // Validação no controller — rejeita termos muito longos ou com caracteres de controle
if (q is { Length: > 100 })
return BadRequest("Termo de busca muito longo.");
if (q?.Any(c => char.IsControl(c)) == true)
return BadRequest("Termo de busca inválido.");
|
2. Latência do índice no Oracle#
O Oracle Text não indexa registros novos automaticamente por padrão. Configure sincronização:
1
2
3
4
5
6
7
| -- Opcional no CREATE INDEX: atualiza o índice a cada commit
CREATE INDEX idx_clientes_fts ON clientes(nome)
INDEXTYPE IS CTXSYS.CONTEXT
PARAMETERS ('... SYNC (ON COMMIT)');
-- Ou agende via DBMS_JOB / DBMS_SCHEDULER para sync periódico:
EXEC CTX_DDL.SYNC_INDEX('idx_clientes_fts', '2M'); -- 2 minutos de janela de memória
|
3. Stopwords e termos curtos#
FTS ignora stopwords (“de”, “do”, “da”, “e”, “o”, “a”…). Uma busca por apenas uma stopword retorna zero resultados, o que pode confundir o usuário. Trate isso:
1
2
3
4
| // No service: se o resultado for zero e o termo tiver apenas stopwords,
// retorne uma mensagem específica ou recaia para LIKE
if (total == 0 && TermoContemApenasStopwords(q))
return await ListarSemFiltroAsync(pagina, tamanho, ct);
|
4. Cache de resultados frequentes#
Buscas com termos populares (“José”, “Maria”) podem ser cacheadas no Redis com TTL curto:
1
2
3
4
5
6
7
| var cacheKey = $"clientes:fts:{q}:p{pagina}:t{tamanho}";
var cached = await cache.GetAsync<PagedResult<ClienteDto>>(cacheKey);
if (cached is not null) return cached;
var resultado = await repo.BuscarAsync(q, pagina, tamanho, ct);
await cache.SetAsync(cacheKey, resultado, TimeSpan.FromSeconds(30));
return resultado;
|
5. Mínimo de caracteres — também na API#
Mesmo com o filtro no Angular, a API deve validar:
1
2
| if (q is { Length: > 0 and < 3 })
return BadRequest("O termo de busca deve ter pelo menos 3 caracteres.");
|
Conclusão#
O Full-Text Search não é uma funcionalidade de nicho — é o caminho certo para qualquer busca textual em tabelas com volume relevante. A diferença entre um LIKE '%termo%' e um CONTAINS() ou @@ pode ser a diferença entre uma query de 8 segundos e uma de 40 milissegundos.
O fluxo completo que vimos:
- Angular com
debounceTime e distinctUntilChanged — evita flood de requisições - Mínimo de 3 caracteres — tanto no frontend quanto na API
- ASP.NET Core com
CancellationToken — cancela consultas se o cliente desconectar - Full-Text Index no banco — índice invertido, não full scan
- Busca por prefixo (
"termo*" / 'termo':* / termo%) — comportamento natural de “busca enquanto digita” - Paginação — porque mesmo FTS com milhares de resultados precisa de paginação
O padrão se repete independentemente do banco: crie o índice invertido específico do banco, use o predicado correto e passe o termo formatado. O EF Core com FromSqlRaw é o ponto de integração — flexível o suficiente para usar o recurso nativo de cada banco sem abrir mão da tipagem forte do C#.
Leia Também#
Referências#
SQL Server#
PostgreSQL#
Oracle#
EF Core e Angular#
Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.