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 @supabase/supabase-js apontando para https://api.superdb.com.br para queries tipadas. Não existe @superdb/superdb-js — o pacote de auth é @superdb/auth-js (sem .from()).

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: { apikey: ANON_KEY, Authorization: `Bearer ${jwt}` } },
)
const data = await r.json()
curl "$SUPERDB_URL/tasks?select=id,title,done" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $JWT"

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

Quer só saber quantas linhas batem o filtro? Use o header Prefer: count=...:

count em 3 modos
// exact: conta tudo, mais lento
await db.from('tasks').select('*', { count: 'exact', head: true })
// .count vem na response

// estimated: usa estatísticas do Postgres, instantâneo
await db.from('tasks').select('*', { count: 'estimated', head: true })

// planned: ainda mais barato, baseado no plano de query
await db.from('tasks').select('*', { count: 'planned', head: true })

head: true evita trazer os dados — só vem o count.

Upsert

"Inserir, ou atualizar se já existir". Precisa de constraint UNIQUE pra Postgres saber o que é "duplicado".

upsert
await db.from('user_settings')
  .upsert(
    { user_id: '...', theme: 'dark', lang: 'pt-BR' },
    { onConflict: 'user_id' }  // coluna com UNIQUE
  )

// REST equivalente:
// POST /user_settings
// Headers: Prefer: resolution=merge-duplicates
// Body: { user_id: ..., theme: ..., lang: ... }

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 proj_X.busca_tarefas(p_query text)
returns setof proj_X.tasks
language sql stable security invoker
as $$
  select * from proj_X.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 o operador or:

OR
// ?or=(priority.eq.high,score.gt.80)
await db.from('tasks').select().or('priority.eq.high,score.gt.80')

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?