Pular para o conteúdo
📖 COOKBOOK

PostgREST como ORM — filtros e joins.

A API REST gerada pelo PostgREST é poderosa: filtros tipo SQL, joins automáticos por foreign key, ordenação, paginação, embedded resources. Aprenda os padrões essenciais.

O que vamos fazer

Dominar os operadores e relacionamentos da API REST do SuperDB pra usar como ORM. Os mesmos padrões funcionam via SDK (db.from('x').select(...)) e via cURL — o SDK só monta a URL pra você.

Vamos cobrir: select de colunas, filtros, ordenação, paginação, joins via FK, filtros em relacionados, count, upsert e RPC.

Pré-requisitos

  • Projeto SuperDB com tabelas criadas no schema do projeto.
  • Foreign keys declaradas — sem FK, não tem join automático.
  • fetch nativo, ou o SDK @superdb/client (com os nomes do supabase-js, já traz .from()) criado com createClient('https://auth.superdb.com.br', ANON_KEY, { project: '<slug>' }). Para REST cru, aponte o fetch para https://api.superdb.com.br mandando o data_plane_token que veio no corpo do login — não o access_token, que é a sessão. Ver Autenticação.

Passo a passo

Selecionar colunas específicas

Por padrão, vem tudo. Pra reduzir payload:

const { data } = await db
  .from('tasks')
  .select('id, title, done')
const r = await fetch(
  `${SUPERDB_URL}/tasks?select=id,title,done`,
  { headers: { Authorization: `Bearer ${data_plane_token}` } },
)
const data = await r.json()
curl "$SUPERDB_URL/tasks?select=id,title,done" \
  -H "Authorization: Bearer $DATA_PLANE_TOKEN"

Filtros básicos

O padrão é ?coluna=operador.valor. Múltiplos params encadeiam com & (AND).

operadores comuns
?done=eq.true              -- equals
?priority=neq.low          -- not equal
?score=gt.50               -- greater than
?score=gte.50              -- greater or equal
?score=lt.100              -- less than
?title=like.*urgente*      -- LIKE (case-sensitive)
?title=ilike.*urgente*     -- ILIKE (case-insensitive)
?priority=in.(high,medium) -- IN
?deleted_at=is.null        -- IS NULL
?tags=cs.{"work"}          -- contains (array/jsonb)
?content=fts.brasil        -- full-text search

Pelo SDK:

src/queries.ts
await db.from('tasks')
  .select()
  .eq('done', true)
  .ilike('title', '%urgente%')
  .in('priority', ['high', 'medium'])
  .is('deleted_at', null)

Ordenar e paginar

ordenação + paginação
// URL: ?order=created_at.desc,priority.asc&limit=20&offset=40

await db.from('tasks')
  .select()
  .order('created_at', { ascending: false })
  .order('priority', { ascending: true })
  .range(40, 59)  // offset 40, limit 20 (range inclusivo)

Alternativa com headers (útil pra cURL):

via headers HTTP
curl "$SUPERDB_URL/tasks?order=created_at.desc" \
  -H "Range-Unit: items" \
  -H "Range: 0-19" \
  -H "apikey: $ANON_KEY"

Joins automáticos via FK

O PostgREST descobre as foreign keys do schema e te deixa fazer "embedded resources" — joins inline sem você escrever SQL. Sintaxe: tabela_relacionada(colunas) dentro do select.

tarefa com user + tags
// tasks.user_id -> users.id  (FK)
// task_tags.task_id -> tasks.id (FK 1:N)

await db.from('tasks').select(`
  id, title, done,
  user:users (id, name, email),
  tags:task_tags (tag)
`)

// resultado:
// [{ id, title, done, user: { id, name, email }, tags: [{ tag: 'x' }] }]
💡

Dica: use !inner pra forçar INNER JOIN — só retorna a linha se o relacionado existir: user:users!inner(name). Sem !inner, vem null quando não tem.

Filtrar por relacionado

Aplique filtros direto nas tabelas embedded:

filtro no relacionado
// só tasks cujo dono é admin
await db.from('tasks')
  .select('id, title, user:users!inner(name, role)')
  .eq('user.role', 'admin')

// URL equivalente:
// ?select=id,title,user:users!inner(name,role)&user.role=eq.admin

Count

Peça a contagem no select. Com head: true, só o total volta, sem as linhas. A contagem respeita o RLS: cada usuário conta só as linhas que enxerga.

quantas tarefas abertas
const { count } = await db
  .from('tasks')
  .select('*', { count: 'exact', head: true })
  .eq('done', false)

// Com as linhas: a página e o total de uma vez
const { data, count: total } = await db
  .from('tasks')
  .select('id, title', { count: 'exact' })
  .range(0, 19)

Pela API REST, sem o SDK: mande Prefer: count=exact e leia o total no header Content-Range (0-0/57 quer dizer 57 linhas). Em tabela muito grande, count=exact percorre tudo; guarde o total numa tabela de resumo se ele aparece em toda tela.

Upsert

"Inserir, ou atualizar se já existir". Precisa de uma constraint UNIQUE (ou a chave primária) para o Postgres saber o que é "duplicado", e de policy de INSERT e de UPDATE:

upsert
// user_settings.user_id: uuid primary key default auth.uid()
const { data } = await db
  .from('user_settings')
  .upsert({ user_id: userId, theme: 'dark', lang: 'pt-BR' }, { onConflict: 'user_id' })
  .select()
  .single()

// Só inserir, e ignorar se já existir
await db.from('inscricoes').upsert({ evento_id, email }, { onConflict: 'evento_id,email', ignoreDuplicates: true })

Pela API REST, é um POST com ?on_conflict=user_id e Prefer: resolution=merge-duplicates.

RPC — chamar funções SQL

Lógica complexa? Defina uma function no banco e chame via /rpc/nome. Útil pra agregar, executar transação, ou bypassar limitações dos filtros REST.

schema.sql
create or replace function busca_tarefas(p_query text)
returns setof tasks
language sql stable security invoker
as $$
  select * from tasks
  where to_tsvector('portuguese', title || ' ' || coalesce(description, ''))
        @@ plainto_tsquery('portuguese', p_query)
    and user_id = auth.uid()
  order by created_at desc
$$;
chamando do client
const { data } = await db.rpc('busca_tarefas', { p_query: 'urgente' })

// REST: POST /rpc/busca_tarefas com body { p_query: '...' }

Resultado

Você tem uma API flexível como ORM, sem precisar de Prisma, Drizzle ou qualquer outro:

  • Filtros que cobrem 95% das queries de aplicação.
  • Joins automáticos via FK — sem N+1, sem hidratação manual.
  • Paginação e count baratos.
  • RPC pra lógica complexa que não cabe em filtros.
  • RLS validando tudo no banco — segurança não fica no cliente.

Variações

Filtros aninhados em relacionados

Filtrar tabela embedded por colunas dela: ?user.created_at=gt.2024-01-01. Sem !inner, isso filtra dentro da tabela relacionada (cada task volta, mas user vem null se não passar no filtro). Com !inner, filtra a tabela principal.

Select com renaming

Renomeie colunas na resposta: ?select=titulo:title,done. Útil quando o front-end espera nome diferente do banco.

ilike é o padrão. Pra busca textual avançada com stemming PT-BR, use FTS: ?title=fts(portuguese).brasileiro — encontra "brasileiros", "brasil", etc.

OR entre filtros

Vários filtros viram AND por padrão. Pra OR, use .or() com a sintaxe do PostgREST (coluna.operador.valor, separados por vírgula):

OR
// prioridade alta OU pontuação acima de 80
const { data: tarefas } = await db
  .from('tasks')
  .select('*')
  .or('priority.eq.high,score.gt.80')

// e o contrário de um filtro
await db.from('tasks').select('*').not('status', 'in', ['arquivada', 'cancelada'])

Um .in('status', ['aberta', 'pausada']) resolve o caso comum de OR na mesma coluna, e esse o SDK tem.

Erros comuns

⚠️

Esquecer o . entre operador e valor: ?done=eqtrue não dá erro — silentemente filtra por "done com valor literal eqtrue" e retorna 0 linhas. Sempre ?done=eq.true.

⚠️

FK não declarada: se a coluna user_id não tem references users(id), o embed user:users(...) retorna sempre null (ou erro 400 dependendo da versão). Cheque com \d tasks no psql.

⚠️

Limit padrão é 1000: se você não passa limit e a tabela tem 50k linhas, vêm só os primeiros 1000 silenciosamente. Sempre pagine.

⚠️

count=exact em tabela grande: faz COUNT(*) de tudo, lento. Em tabelas com milhões de linhas, prefira estimated ou planned — diferença de centenas de ms vs alguns ms.

⚠️

Filtro em coluna nula: ?coluna=eq.null não funciona (PG não permite = NULL). Use ?coluna=is.null e ?coluna=not.is.null.

Essa página ajudou?