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.
fetchnativo, ou o SDK@superdb/client(com os nomes do supabase-js, já traz.from()) criado comcreateClient('https://auth.superdb.com.br', ANON_KEY, { project: '<slug>' }). Para REST cru, aponte ofetchparahttps://api.superdb.com.brmandando odata_plane_tokenque veio no corpo do login — não oaccess_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).
?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:
await db.from('tasks')
.select()
.eq('done', true)
.ilike('title', '%urgente%')
.in('priority', ['high', 'medium'])
.is('deleted_at', null)
Ordenar e paginar
// 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):
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.
// 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:
// 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.
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:
// 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.
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
$$;
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.
Busca case-insensitive
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):
// 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.