Este guia mostra como consultar uma org Salesforce diretamente do DuckDB
usando a extensão duckdb-salesforce. A extensão anexa sua org como um
catálogo somente-leitura: objetos Salesforce (sObjects) aparecem como
tabelas das quais você faz SELECT com SQL comum, enquanto a extensão
traduz sua consulta em SOQL e escolhe a API correta do Salesforce (REST ou
Bulk) nos bastidores.
É prático e baseado em exemplos. Cada configuração e função citada aqui é real; nada foi inventado.
Este guia segue conceitos documentados pelo DuckDB:
ATTACH: anexa outro catálogo ao DuckDB. Esta extensão usa o mesmo modelo para expor uma org Salesforce como catálogo somente-leitura.SELECT: o único comando que você executa contra o catálogo Salesforce. Toda escrita e DDL lança erro.CREATE TABLE:CREATE TABLE ... AS SELECTmaterializa uma consulta Salesforce em uma tabela local do DuckDB.- Table functions:
esta extensão fornece funções de diagnóstico como
salesforce_query_cost().
ATTACH expõe a org como catálogo. Você só pode ler dele: todo INSERT,
UPDATE, DELETE e DDL contra o catálogo Salesforce lança erro. Se você
precisa de uma cópia local gravável, materialize uma consulta em uma tabela
DuckDB (veja a seção 5).
Cada tabela no catálogo anexado é um sObject do Salesforce (Account,
Contact, Opportunity, objetos personalizados terminados em __c, e
assim por diante). O schema é resolvido sob demanda (lazy) na
primeira vez que você referencia um objeto, então anexar é barato e rápido.
A autenticação é OAuth 2.0 apenas por refresh-token. Você fornece um
client_id, um client_secret e um refresh_token. As credenciais ficam
somente em memória e nunca são registradas em log. A verificação de
certificado TLS está sempre ativa.
Um build local não é assinado, então o DuckDB não o carrega sem que você permita explicitamente:
SET allow_unsigned_extensions=true;
LOAD 'caminho/para/salesforce.duckdb_extension';Para os passos de build e instalação por plataforma, veja docs/en/guide_windows.md (Windows) e docs/en/guide_linux.md (Linux).
As plataformas validadas no CI hoje são linux_amd64 + windows_amd64
(baseline) e osx_arm64 (extra). A extensão ainda não foi publicada no catálogo
community, então ainda não existe um INSTALL ... FROM community.
A extensão sempre verifica o certificado TLS do servidor — não existe
flag insegura para desligar essa checagem em nenhuma plataforma. No macOS,
porém, o OpenSSL (compilado via vcpkg) não traz um CA bundle padrão e
não lê o Keychain do macOS. Por causa disso, um ATTACH live pode
falhar na verificação de certificado.
A solução é apontar o OpenSSL para um CA bundle através da variável de
ambiente SSL_CERT_FILE (o OpenSSL também respeita SSL_CERT_DIR). Isso
apenas escolhe as âncoras de confiança — a verificação continua ligada,
não é um bypass:
export SSL_CERT_FILE=$(brew --prefix)/etc/openssl@3/cert.pem # bundle do OpenSSL via Homebrew
export SSL_CERT_FILE=$(python3 -m certifi) # bundle certifi do PythonUm ATTACH live que falha a verificação no macOS já imprime exatamente essa
sugestão na mensagem de erro. Linux e Windows não precisam disso: o
Linux usa o bundle do sistema e o Windows usa o trust store do SO. Um trust
store zero-config baseado no Keychain do macOS é um follow-up planejado, ainda
não entregue.
Anexe a org com o tipo de catálogo salesforce e suas credenciais OAuth:
ATTACH 'salesforce://<org>' AS sf (
TYPE salesforce,
client_id 'sua_consumer_key',
client_secret 'seu_consumer_secret',
refresh_token 'seu_refresh_token'
);Dois parâmetros opcionais cobrem sandboxes e versões de API fixadas:
ATTACH 'salesforce://<org>' AS sf (
TYPE salesforce,
client_id 'sua_consumer_key',
client_secret 'seu_consumer_secret',
refresh_token 'seu_refresh_token',
login_url 'https://login.salesforce.com',
api_version 'v60.0'
);Use login_url 'https://test.salesforce.com' para uma sandbox. Para obter
o client_id, o client_secret e o refresh_token, configure um
Connected App na sua org e conclua o fluxo OAuth conforme descrito em
docs/CONNECTED_APP.pt-BR.md.
Para padronização de política, o conector também aceita read_only como
opção de ATTACH (ex.: quando sua ferramenta anexa READ_ONLY TRUE a toda
fonte). Ele é aceito e ignorado: o catálogo é somente leitura por design -
toda operação de escrita lança erro - então a flag não muda nada.
Por padrão, o ATTACH lê as credenciais das próprias opções do comando
(como nos exemplos acima). A opção auth_source permite escolher de onde
elas vêm, o que é útil para não deixar segredos no texto SQL. Os quatro
modos são:
'options'(padrão) — credenciais nas opções doATTACH:client_id,client_secreterefresh_token(obrigatórios), mais os opcionaislogin_urleapi_version.'env'— credenciais nas variáveis de ambienteSF_CLIENT_ID,SF_CLIENT_SECRETeSF_REFRESH_TOKEN(obrigatórias), mais a opcionalSF_LOGIN_URL.'sfdx_url'— credenciais na variávelSF_SFDX_AUTH_URL, no formatoforce://<clientId>:<clientSecret>:<refreshToken>@<host-da-instancia>(oclientSecretpode ser vazio).'jwt'— fluxo OAuth 2.0 JWT bearer, ideal para uso headless/CI/pipeline. Sem refresh token: a extensão assina um JWT curto (RS256) e o troca por um access token mantido em memória; diante de um401, re-assina um novo assertion. Exigeclient_id(o issuer),username(o subject — o usuário Salesforce a personificar) e uma chave privada, cujo caminho vem da variável de ambienteSF_JWT_KEY_FILE(recomendado) ou da opção inlineprivate_key_file(apenas dev local). A chave deve ser PEM RSA PKCS#1 ou PKCS#8 não criptografada.login_urlé opcional (padrãohttps://login.salesforce.com; usehttps://test.salesforce.compara sandbox).
api_version funciona em todos os modos. Com 'env', 'sfdx_url' ou
'jwt', você não precisa informar as opções de credencial no ATTACH
— a fonte escolhida vence.
Pré-requisito do modo
'jwt': o Connected App deve estar pré-autorizado — admin-approved, ou o usuário pré-autorizado (a configuração de certificado digital / "Use digital signatures" do JWT).
Exporte as variáveis no shell e então rode o DuckDB e o ATTACH com
auth_source 'env':
export SF_CLIENT_ID=sua_consumer_key
export SF_CLIENT_SECRET=seu_consumer_secret
export SF_REFRESH_TOKEN=seu_refresh_token
duckdbATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'env');Como alternativa, use a URL SFDX da CLI do Salesforce e auth_source 'sfdx_url':
export SF_SFDX_AUTH_URL=$(sf org display --verbose --json | jq -r .result.sfdxAuthUrl)
duckdbATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'sfdx_url');Para o modo 'jwt', exporte o caminho da chave em SF_JWT_KEY_FILE e então
faça o ATTACH com client_id e username:
export SF_JWT_KEY_FILE=/secure/server.key
duckdbATTACH 'salesforce://production' AS sf (
TYPE salesforce,
auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY',
username 'svc@example.com'
);Apenas para desenvolvimento local, você pode informar a chave inline com
private_key_file (em pipelines, prefira a env var):
ATTACH 'salesforce://production' AS sf (
TYPE salesforce,
auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY',
username 'svc@example.com',
private_key_file '/secure/server.key'
);O app ou container injeta as variáveis SF_* (por exemplo, a partir de um
secret manager). O backend abre o DuckDB e faz o ATTACH com auth_source 'env' — assim, nenhuma credencial aparece no texto SQL:
ATTACH 'salesforce://production' AS sf (TYPE salesforce, auth_source 'env');Para o modo 'jwt', o app ou container injeta SF_JWT_KEY_FILE (por
exemplo, um secret montado) e o backend faz o ATTACH sem caminho de chave
nem secret no SQL:
ATTACH 'salesforce://production' AS sf (
TYPE salesforce,
auth_source 'jwt',
client_id 'YOUR_CONSUMER_KEY',
username 'svc@example.com'
);Defina as variáveis no ambiente do processo e faça o ATTACH com
auth_source 'env':
import os, duckdb
os.environ["SF_CLIENT_ID"] = "sua_consumer_key"
os.environ["SF_CLIENT_SECRET"] = "seu_consumer_secret"
os.environ["SF_REFRESH_TOKEN"] = "seu_refresh_token"
con = duckdb.connect()
con.execute(
"ATTACH 'salesforce://prod' AS sf (TYPE salesforce, auth_source 'env')"
)Para o modo 'jwt', defina SF_JWT_KEY_FILE no ambiente do processo e
informe client_id e username no ATTACH:
import os, duckdb
os.environ["SF_JWT_KEY_FILE"] = "/secure/server.key"
con = duckdb.connect()
con.execute(
"ATTACH 'salesforce://prod' AS sf (TYPE salesforce, auth_source 'jwt', "
"client_id '...', username 'svc@example.com')"
)Os valores das variáveis de ambiente e a URL SFDX nunca são logados, e
tokens ou secrets nunca aparecem em mensagens de erro. Os erros são claros:
uma variável de ambiente ausente é nomeada (apenas o nome, sem valor); uma
URL SFDX malformada é sinalizada sem ser ecoada; invalid_grant vira
"refresh token inválido, expirado ou revogado"; e invalid_client vira
"client_id / client_secret incorreto".
No modo 'jwt', a chave privada, o JWT montado e o assertion assinado nunca
são logados e nunca aparecem em mensagens de erro. Os erros continuam
claros: um caminho de chave ausente nomeia a opção/env var que faltou
(SF_JWT_KEY_FILE ou private_key_file); um arquivo de chave ausente
nomeia apenas o caminho; uma chave não-PEM, ilegível ou criptografada é
reportada sem ecoar o conteúdo; e invalid_grant vira "inválido, expirado,
ou o usuário não está autorizado neste Connected App".
Os seguintes recursos não são suportados nesta versão: fluxo OAuth via browser/web, secret storage, chaves privadas criptografadas, gerenciador de credenciais do sistema operacional e persistência de token/chave.
Uma vez anexada, consulte sObjects como qualquer outra tabela:
SELECT Id, Name
FROM sf.Account
WHERE Name = 'Acme'
LIMIT 10;Literais de Id em filtros são validados localmente em tempo de plano: um
literal de =, != ou IN comparado a um campo do tipo Id precisa ser um Id
Salesforce real (15 ou 18 caracteres alfanuméricos). Um valor malformado falha
de imediato com um erro claro em vez do genérico
INVALID_QUERY_FILTER_OPERATOR do Salesforce.
O schema é resolvido sob demanda (lazy) na primeira referência. Para listar os objetos que você pode consultar, execute um único describe global via:
SHOW TABLES;
-- ou
SELECT * FROM duckdb_tables();Custom Metadata Types (objetos cujo nome de API termina em __mdt) e Custom
Settings consultáveis (objetos terminados em __c, dos tipos List e
Hierarchy) aparecem como tabelas sObject comuns, somente-leitura, no
catálogo anexado. Não há sintaxe especial nem tratamento especial: quando são
consultáveis, eles aparecem na listagem global de objetos (SHOW TABLES /
duckdb_tables()), e DESCRIBE + SELECT funcionam exatamente como em
qualquer outro objeto — inclusive projection e predicate pushdown.
-- um Custom Metadata Type
SELECT DeveloperName, Label
FROM sf.Something__mdt
LIMIT 10;
-- uma Custom Setting consultável
SELECT Id, Name
FROM sf.SomeSetting__c
LIMIT 10;Pontos a conhecer:
- Isto é acesso a dados, não a Metadata API. O conector apenas lê os registros como linhas; ele não faz deploy, não cria nem modifica metadados.
- A visibilidade segue as permissões do usuário/org autenticado. Custom Metadata protegido ou Custom Settings protegidos podem não aparecer ou não ser legíveis, conforme as regras do próprio Salesforce.
- Tudo é somente-leitura. Como em qualquer sObject, toda escrita lança erro (o catálogo é somente-leitura).
- Se você adicionar um novo tipo
__mdtou um novo campo durante a sessão, chamesalesforce_refresh_metadata('<catalog>')(ousalesforce_refresh_metadata('<catalog>', '<Object>')) para que a próxima referência re-descreva o schema.
Para manter um snapshot local e consultável (e fugir das limitações de somente-leitura), copie uma consulta Salesforce para uma tabela DuckDB:
CREATE TABLE contas_locais AS
SELECT Id, Name, Industry, AnnualRevenue
FROM sf.Account
WHERE Industry = 'Manufacturing';A partir daí você pode consultar, juntar e agregar contas_locais sem
novas chamadas de API.
A extensão pode buscar linhas por diferentes APIs do Salesforce. Escolha
com sf_force_transport:
-- REST (padrão): paginação lazy por /query + queryMore
SET sf_force_transport='rest';
-- Bulk API 2.0: páginas de resultado em streaming lazy, com PK chunking opcional
SET sf_force_transport='bulk';
-- auto: sonda a contagem de linhas e então escolhe rest ou bulk
SET sf_force_transport='auto';- rest (padrão) usa o caminho de paginação REST
/query+queryMore, buscado de forma lazy. - bulk usa a Bulk API 2.0. As páginas de resultado vêm em streaming lazy, com PK chunking opcional (seção 7).
- auto executa um
SELECT COUNT()para sondar a contagem de linhas e então escolhe rest ou bulk com base emsf_auto_bulk_threshold(padrão 50000). A sondagem é controlada porsf_auto_probe(padrão true).
Importante sobre LIMIT com Bulk: um LIMIT no Bulk não é
server-side, então o job roda por completo. Porém, como as páginas de
resultado vêm em streaming lazy, um LIMIT pequeno interrompe o download
das páginas seguintes. Note que o auto não enxerga o LIMIT ao sondar,
então para uma consulta com LIMIT pequeno contra um objeto enorme, force
rest explicitamente:
SET sf_force_transport='rest';
SELECT Id, Name FROM sf.Account ORDER BY CreatedDate DESC LIMIT 20;COUNT(*) e scans de zero colunas executam um SELECT COUNT() (sem
paginação de registros) quando não há filtro residual e o scan não foi
forçado para bulk:
SELECT COUNT(*) FROM sf.Contact;A Bulk API 2.0 query do Salesforce, no formato CSV, não retorna campos
blob/base64. Forçar um desses campos faz o próprio Salesforce devolver o
erro Blob field not supported in Bulk V2 Query with CSV content type. No
DuckDB, os campos base64 do Salesforce mapeiam para o tipo BLOB.
Para evitar essa falha, a extensão tem um guard guiado por metadados: se um
campo cujo tipo é base64 estiver projetado no scan — em qualquer
profundidade, inclusive dentro de um STRUCT de relacionamento pai — o
transporte Bulk é tratado como incompatível. O comportamento depende de
sf_force_transport:
auto— ao detectar um campobase64projetado, o scan permanece em REST e nunca escolhe Bulk. O motivo registrado éauto: bulk-incompatible (projected base64 field 'NAME') -> rest.bulk(forçado) — a extensão falha com um erro claro antes de criar qualquer job:projected base64 field 'NAME' is not supported by Bulk API 2.0 CSV; use 'rest' or 'auto'.rest— inalterado. O REST retorna os campos blob comobase64no JSON.
Se o campo base64 não for projetado, o Bulk é permitido normalmente.
Orientação prática: para ler uma coluna blob/base64, use REST (o padrão) ou
simplesmente não a projete num scan Bulk. A decisão e o motivo aparecem em
salesforce_last_transport() e salesforce_query_cost() (colunas
transport e reason).
Nota de escopo: este guard é guiado por metadados e cobre apenas campos
base64. Outros objetos não suportados pelo Bulk não são pré-listados; se
forçados, vão aparecer como um erro claro do próprio job Bulk do Salesforce.
A subseção anterior (Bulk) é só metade do quadro. O REST também tem uma
limitação específica para campos BODY blob/base64 de objetos como
Attachment.Body e ContentVersion.VersionData: nessas colunas, uma query
REST do Salesforce não devolve o conteúdo em base64 inline — devolve uma
URL reference que aponta para o blob.
O scanner não segue essa URL: não há download automático de anexo/blob. Selecionar um desses campos via REST levanta um erro claro e documentado:
Salesforce returned a URL reference for blob/base64 field 'NAME'; inline
BLOB decoding is not supported by REST query. Select non-blob fields or
fetch the blob URL outside the scanner.
Orientação prática: selecione apenas os campos não-blob do objeto no
scan, e busque o conteúdo do blob fora do scanner, diretamente pela API
do Salesforce, usando o Id do registro.
Importante: esta limitação é específica dos campos BODY blob retornados
como URL reference. Campos base64 pequenos/inline que o REST realmente
devolve embutidos no JSON continuam decodificando para BLOB normalmente.
Juntando os dois lados acima, para campos BODY blob/base64:
- Bulk (subseção anterior) — não suportado: o guard falha com erro claro
ou, em
'auto', mantém o scan em REST. - REST (esta subseção) — retorna uma URL reference, não decodificável inline; erro claro.
Resumo: campos blob body não são lidos diretamente como bytes por este
conector em nenhum transporte. Busque-os fora do scanner, pela API
do Salesforce, usando o Id do registro.
O conector espera que os campos datetime, date e time do Salesforce
cheguem como strings ISO 8601 e os decodifica para os tipos TIMESTAMP,
DATE e TIME do DuckDB. Exemplos do formato esperado:
datetime -> 2024-01-15T10:20:30.000+0000 (TIMESTAMP)
date -> 2024-01-15 (DATE)
time -> 10:20:30.000Z (TIME)
Pontos a conhecer:
- Paridade entre transportes. O mesmo decode tipado vale nos dois
transportes — tanto no REST (JSON) quanto no Bulk (CSV). Você obtém o mesmo
TIMESTAMP/DATE/TIMEindependentemente de como as linhas foram buscadas. - Normalização para UTC wall-clock. Os timestamps são normalizados para
o valor UTC wall-clock: o offset de timezone é removido, não deslocado.
O instante
2024-01-15T10:20:30.000+0000vira oTIMESTAMP2024-01-15 10:20:30. - Valores numéricos / epoch não são interpretados. Um valor numérico (por
exemplo
1705314030000, um epoch em milissegundos) num campodatetime/date/timenão é adivinhado. O conector não tenta decidir entre segundos e milissegundos, então prefere falhar claro a produzir um timestamp incorreto: ele levanta um erro nomeando o campo e o tipo, comofield 'Whn' (Salesforce type 'datetime') could not be decoded as TIMESTAMP. O valor ofensor nunca é ecoado na mensagem. Isto é intencional.
Na prática, uma Bulk API 2.0 query devolvendo datetimes em epoch não é um caso
confirmado; se uma org real fizer isso, trate como um bug a reportar com
exemplo, não como algo que o conector deva adivinhar. Se você precisar
trabalhar com um valor de tempo numérico/epoch, faça a transformação fora do
scanner — por exemplo, aplique to_timestamp do DuckDB sobre o número bruto
obtido por outro caminho.
Falhas HTTP transientes (429, 5xx, erros de conexão) são retentadas
automaticamente: sf_retry_max tentativas (padrão 3) com backoff linear
sf_retry_backoff_ms (padrão 200 ms). Cada tentativa espera no máximo
sf_timeout_read_s segundos (padrão 60, teto 3600) pela resposta do
servidor.
Aumente o timeout quando um COUNT() server-side legítimo ou um scan em um
objeto muito grande o excede e falha em vez de completar - por exemplo
SET sf_timeout_read_s = 300. Veja o manual de funções para a semântica
completa.
Para objetos muito grandes, a Bulk API 2.0 mais PK chunking paraleliza a
extração. sf_bulk_chunks (padrão 1 = desligado, limite 8, apenas Bulk)
divide um scan Bulk em N faixas de Id disjuntas. A extensão sonda
MIN(Id) / MAX(Id), faz uma divisão lexical e executa um job Bulk por
chunk em paralelo (até uma thread por chunk):
SET sf_force_transport='bulk';
SET sf_bulk_chunks=8;
CREATE TABLE todas_oportunidades AS
SELECT Id, Name, Amount, StageName, CloseDate
FROM sf.Opportunity;Ressalvas: não há ordem global de linhas entre chunks, e os chunks
podem ser desiguais ou até vazios, dependendo de como os valores de Id se
distribuem.
A primeira carga completa de um objeto grande é justamente a falha que
motivou o trabalho de pushdown de faixa: um único Bulk de milhões de linhas
pode estourar o orçamento de polls, e uma janela CreatedDate >= X AND CreatedDate < Y só ajuda se essa faixa de fato for empurrada para o SOQL.
Siga esta sequência em vez de apontar o Bulk para o objeto inteiro.
1. Prove que o predicado é empurrado antes de extrair. Um WHERE que
parece seletivo não vale nada se quem filtra é o DuckDB depois de um scan
remoto completo. Rode a janela por salesforce_query_cost() e confirme que a
faixa é server-side:
SELECT Id FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00'
LIMIT 0;
SELECT where_pushed, pushed_filters, residual_filters, guidance
FROM salesforce_query_cost();
-- esperado: where_pushed contém a faixa de CreatedDate, residual_filters = 0,
-- guidance = "Salesforce is filtering server-side". Se em vez disso disser
-- "DuckDB is filtering after a full remote scan", a faixa NÃO foi empurrada —
-- corrija o predicado antes de extrair (veja a seção 13 sobre o que fica residual).COUNT(*) sobre a mesma janela é empurrado para um único SELECT COUNT() e
devolve a contagem por janela de graça (zero egresso de registros), então dá
para dimensionar cada janela:
SELECT COUNT(*) FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00';
salesforce_aggregate()(seção 11) também valida a seletividade server-side, mas prova apenas que o Salesforce consegue filtrar a faixa — uma extração normal ainda precisa provar que o scan empurra o mesmo predicado viasalesforce_query_cost(). São caminhos de código diferentes.
2. Divida o seed em janelas de CreatedDate (ou SystemModstamp)
empurradas e grave cada uma via DuckDB em Parquet ou tabela de lake. Cada
janela é um Bulk limitado:
SET sf_force_transport='bulk';
SET sf_bulk_require_predicate=true; -- recusa leitura acidental do objeto inteiro
SET sf_bulk_poll_budget=2000; -- uma janela grande pode precisar de > 600 polls
COPY (
SELECT Id, Name, Amount, CreatedDate, SystemModstamp
FROM sf.Produto_Oferta__c
WHERE CreatedDate >= TIMESTAMP '2024-01-01 00:00:00'
AND CreatedDate < TIMESTAMP '2024-02-01 00:00:00'
) TO 'lake/produto_oferta/2024-01.parquet' (FORMAT parquet);Com sf_bulk_require_predicate=true, uma leitura Bulk cujo predicado não foi
empurrado falha rápido antes de criar o job — então o erro aparece como
erro, não como over-fetch de milhões de linhas. Após cada janela, confira
salesforce_query_cost().bulk_polls para ver o quão perto o job chegou do
orçamento e aumente sf_bulk_poll_budget se preciso.
3. Mantenha incrementalmente por SystemModstamp. Depois do seed,
atualize só as linhas alteradas com uma janela meio-aberta a partir da última
marca-d'água, relendo um lookback curto para absorver desvio de relógio:
COPY (
SELECT Id, Name, Amount, CreatedDate, SystemModstamp
FROM sf.Produto_Oferta__c
WHERE SystemModstamp >= TIMESTAMP '2024-06-01 00:00:00' -- última marca-d'água - lookback
) TO 'lake/produto_oferta/incr-2024-06.parquet' (FORMAT parquet);Conduza as janelas e marcas-d'água pelo seu orquestrador (dbt, scheduler etc.); a extensão em si não gerencia checkpoints nem agendamentos.
Para evitar consumir sua cota diária de API, uma governança de quota
controla o início de jobs Bulk (o REST não é controlado). Antes de
iniciar um job Bulk, a extensão lê GET /limits e recusa o job quando as
requisições restantes estão em ou abaixo de um limite:
limite = max(sf_quota_min_remaining, sf_quota_reserve_pct% de DailyApiRequests.Max)
Padrões: sf_quota_min_remaining é 1000 e sf_quota_reserve_pct é 10
(por cento). Configurações relevantes:
SET sf_quota_enabled=true; -- chave mestra (padrão true)
SET sf_quota_enforce=true; -- false = apenas avisa (padrão true)
SET sf_quota_fail_open=true; -- /limits indisponível -> permite (padrão true)
SET sf_quota_cache_seconds=60; -- cache do resultado de /limits (padrão 60)
SET sf_quota_min_remaining=1000; -- piso absoluto (padrão 1000)
SET sf_quota_reserve_pct=10; -- reserva % do máximo diário (padrão 10)Respostas HTTP 429 são repetidas automaticamente;
REQUEST_LIMIT_EXCEEDED é terminal.
Por padrão, colunas de relacionamento ficam desligadas. Defina
sf_relationships='parent' para expor lookups de pai de alvo único como
colunas STRUCT:
SET sf_relationships='parent';
SELECT Account.Name
FROM sf.Contact
LIMIT 10;Escopo e ressalvas:
- A profundidade padrão é 1 (apenas o pai direto); use
sf_relationship_depthpara estender até o avô (veja abaixo). - Lookups polimórficos são ignorados.
- Predicados sobre subcampos são avaliados como filtros residuais (no DuckDB).
- O padrão é
'off'.
Com sf_relationships='parent', a configuração sf_relationship_depth
controla quantos níveis a expansão desce. O padrão é 1 (só o pai direto);
com 2, o pai (single-target) de um relacionamento de pai single-target
também é expandido, como um STRUCT aninhado:
SET sf_relationships='parent';
SET sf_relationship_depth=2;
SELECT Account.Owner.Name
FROM sf.Contact
LIMIT 10;Aqui o caminho é Contact → Account → Owner (um User). Ressalvas:
- A profundidade é limitada a 2 (não os 5 níveis do SOQL).
- Cada salto precisa ser single-target; relacionamentos polimórficos são pulados em qualquer nível.
- Predicados sobre subcampos (por exemplo,
Account.Owner.NamenoWHERE) continuam residuais, sem pushdown. - O over-fetch cresce com a profundidade: projetar o STRUCT busca todos os campos escalares de cada nível expandido.
sf_schema_source controla como o schema dos objetos é descoberto:
-- describe (padrão): describe via REST, autoritativo
SET sf_schema_source='describe';
-- tooling: FieldDefinition da Tooling em lote, rápido
SET sf_schema_source='tooling';- describe (padrão) usa a chamada describe do REST e é autoritativo.
- tooling usa a API Tooling
FieldDefinitionem lote, rápida, com fallback REST por objeto. Ela produz tipos mais grosseiros e reduz o pushdown, então prefira-a apenas quando o describe for lento demais para o seu fluxo.
A extensão fornece funções de tabela voltadas ao usuário que explicam o que
o último scan fez. A mais completa é salesforce_query_cost():
SELECT * FROM salesforce_query_cost();Ela retorna, entre outras colunas: object, soql, transport,
est_rows, transport_reason, projected_fields, total_fields,
pushed_filters, residual_filters, where_pushed, pages_fetched,
rows_emitted, bulk, count_pushdown, bulk_chunks, bulk_polls,
quota_remaining, quota_allowed e guidance. guidance diz se foi o
Salesforce que filtrou server-side ou se foi o DuckDB que filtrou após um
scan remoto completo; bulk_polls é a contagem de polls de status do job
Bulk.
Auxiliares focados retornam um único aspecto do último scan:
SELECT * FROM salesforce_last_transport(); -- transporte realmente usado
SELECT * FROM salesforce_last_quota(); -- snapshot da quota
SELECT * FROM salesforce_last_soql(); -- SOQL que foi enviado
SELECT * FROM salesforce_last_scan_pages(); -- páginas buscadasUm fluxo típico é executar sua consulta e então inspecionar
salesforce_query_cost() para confirmar o transporte, os filtros com
pushdown vs. residuais e a coluna guidance para dicas de ajuste.
Dois diagnósticos de metadados somente leitura, servidos pelo Metadata Engine compartilhado (um Describe Global de-duplicado + Describe por objeto por catálogo), permitem explorar o schema da org sem sair do DuckDB:
-- O que dá para consultar aqui?
SELECT object_name FROM salesforce_metadata_objects('sf')
WHERE queryable ORDER BY object_name;
-- Quais campos de Account, e quais podem ser filtrados server-side?
SELECT field_name, type, filterable, sortable, relationship_name, reference_to, picklist_values
FROM salesforce_metadata_fields('sf', 'Account')
ORDER BY field_name;filterable é a coluna-chave para ajuste: um WHERE em campo não-filtrável não
pode ser empurrado para o Salesforce e é aplicado residualmente no DuckDB. O
cache é invalidado por salesforce_refresh_metadata() (ver abaixo).
salesforce_query_cost() resume um scan em uma linha; salesforce_query_explain()
detalha o mesmo último scan campo a campo, para você ver exatamente qual
predicado foi empurrado ao Salesforce e qual ficou para o DuckDB:
SELECT count(*) FROM sf.Account WHERE Industry = 'Technology';
SELECT object_name, field_name, role, pushed, residual, reason
FROM salesforce_query_explain()
ORDER BY role, field_name;Leia o resultado por role:
- Linhas
filter—pushed=true, reason='pushed_to_soql'significa que o Salesforce filtrou server-side (bom).residual=truecomreason='not_filterable'significa que o campo não é filtrável, então o DuckDB filtrou depois de um scan remoto completo — over-fetch a evitar.complex_expression(predicadoOR/NOT/cross-field,field_nameNULL) eunsupported_operatortambém são residuais. - Linhas
projection— uma por campo selecionado (reason='projected'); um campo de referência também mostrarelationship_name+reference_to. - Linhas
relationship— uma por relacionamento pai realmente atravessado (reason='relationship_traversed'). - Linha
count—count_pushdown(servida porSELECT COUNT(), sem buscar registros) oucount_not_pushed. - Linha
transport—transport_restoutransport_bulk; a colunaguidancecarrega o motivo,queryAlleest_rows.
Comparando os dois diagnósticos:
-- resumo de uma linha
SELECT object, transport, pushed_filters, residual_filters, where_pushed, count_pushdown
FROM salesforce_query_cost();
-- detalhamento por campo do MESMO scan
SELECT field_name, role, pushed, residual, reason FROM salesforce_query_explain();Notas:
- Último scan, sem parâmetros.
salesforce_query_explain()reflete o scan mais recente da sessão e não recebe argumentos. Antes de qualquer scan retorna zero linhas (nunca inventa uma linha padrão). metadata_unavailable. Se o catálogo foi desanexado (ou os metadados estão indisponíveis), as linhas anotadas pela engine degradam parareason='metadata_unavailable'comresolved=false; as linhascount/transportcontinuam aparecendo. A função nunca dá erro.LIMITnão aparece. Neste build do DuckDB a table function nunca recebe oLIMITda query (o DuckDB o aplica acima do scan), então não é diagnosticável e é omitido, não chutado.- Apenas diagnóstico. Nenhuma das funções muda como os scans rodam; elas explicam, não aceleram. O comportamento do scan permanece inalterado.
salesforce_relationships() explica o que a expansão de relacionamentos de
pai fez na última resolução de schema de um objeto (a resolução ocorre na
primeira referência ao objeto, não no scan). Use-a para entender o over-fetch,
quais relacionamentos foram pulados e quão fundo a expansão desceu:
SET sf_relationships = 'parent';
SET sf_relationship_depth = 2;
SELECT Id FROM sf.Contact LIMIT 1; -- dispara a resolução de schema
SELECT row_type, relationship_name, parent_object, depth_level, status, reason, field_count
FROM salesforce_relationships();Como ler a saída:
- A primeira linha é sempre
row_type = config: trazrelationships_mode(off/parent), orelationship_depthefetivo (1..2) e os contadoresexpanded_count/skipped_count. Essa linha é emitida mesmo comsf_relationships = 'off'— nesse caso você recebe só a linhaconfig, então "off" nunca parece um resultado vazio ou quebrado. - Cada linha
row_type = relationshipé um camporeferenceconsiderado. Quandostatus = expanded, a colunafield_countmostra quantos campos o STRUCT do pai tem e anoteavisa do over-fetch: o pai é buscado inteiro (todos os campos escalares queryable), pois a projeção aninhada não recebe pushdown — selecionarAccount.Nameainda busca oAccountcompleto. - Quando
status = skipped, a colunareasondiz o porquê (polymorphic,self_reference,cycle,name_collision,parent_not_describable,no_fieldsouno_relationship_name). É assim que você descobre por que uma coluna de relacionamento esperada não apareceu. - A coluna
depth_leveldistingue o pai direto (1) do avô (2), então você vê exatamente o que a profundidade 2 acrescentou.
Por exemplo, em sf.Contact com profundidade 2 você costuma ver Account
expandido em depth_level = 1, Owner expandido em depth_level = 2
(o User dono do Account) e What pulado com reason = polymorphic
(parent_object NULL, pois há mais de um alvo).
O conector resolve o schema de cada objeto uma vez, na primeira referência,
e o mantém em cache em memória pelo tempo do ATTACH (junto do object listing
global da org). Numa sessão curta isso é ótimo, mas numa sessão longa há schema
drift: alguém adiciona um campo custom em Account, ou cria um objeto novo,
e a sua sessão continua enxergando o schema antigo. Antes era preciso fazer
DETACH e ATTACH de novo.
salesforce_refresh_metadata resolve isso invalidando o cache em memória, para
que a próxima query rebusque os metadados — a função não faz a chamada de
rede sozinha, só limpa o cache.
-- adicionaram um campo em Account na org; re-descreve só Account na próxima query:
SELECT * FROM salesforce_refresh_metadata('sf', 'Account');
-- criaram objetos novos / mudou bastante coisa: descarta schemas + object listing:
SELECT * FROM salesforce_refresh_metadata('sf');A distinção de escopo é o ponto principal:
- Com
objecto refresh é cirúrgico: só o schema daquele objeto é descartado; os demais objetos e o object listing continuam em cache. - Sem
objecto refresh é global: o object listing e todos os schemas já resolvidos são descartados, então a próxima varredura de objetos e a próxima referência a qualquer objeto rebuscam tudo.
A função devolve uma linha com catalog, scope (global ou object) e
object (NULL no refresh global). O catalog precisa ser um catálogo
Salesforce anexado, senão você recebe um erro claro de alias inexistente ou de
catálogo que não é Salesforce.
Duas table functions de metadados somente leitura expõem, direto no SQL,
o catálogo de valores de um campo picklist e os record types de um objeto.
Ambas reutilizam a sessão autenticada de um catálogo já anexado por ATTACH
(o primeiro argumento é o alias, ex. 'sf') e leem a partir do REST
describe do objeto — não é a Metadata API (sem SOAP, sem Tooling) e não há
deploy/retrieve/CRUD.
salesforce_picklist_values(catalog, object, field) devolve uma linha por
valor do campo picklist, com as colunas value, label, active e
is_default. O escopo é o catálogo completo do campo: traz os valores
ativos e inativos, e is_default marca o default. Ela não filtra por
record type e não resolve dependent picklists. Para ver só os ativos,
filtre com WHERE active:
-- todos os valores de picklist de Industry (ativos + inativos)
SELECT value, label FROM salesforce_picklist_values('sf', 'Account', 'Industry');
-- só ativos
SELECT value FROM salesforce_picklist_values('sf', 'Account', 'Industry') WHERE active;salesforce_record_types(catalog, object) devolve uma linha por record
type, com as colunas developer_name, label, record_type_id, active
e is_default:
-- record types
SELECT developer_name, label, is_default FROM salesforce_record_types('sf', 'Account');Pontos a conhecer:
- Cache por
ATTACH. O describe raw do objeto é buscado uma vez por objeto e reutilizado pelas duas funções e por chamadas repetidas (cache em memória). Usesalesforce_refresh_metadata('<catalog>')ousalesforce_refresh_metadata('<catalog>', '<Object>')para limpá-lo e forçar a próxima chamada a re-descrever o objeto. - Erros claros (sem segredos). Alias de catálogo desconhecido, ou um alias que não é catálogo Salesforce, ou (no caso do picklist) um campo inexistente no objeto → erro claro.
- Campo não-picklist → 0 linhas. Um campo que existe mas não é picklist
devolve zero linhas em
salesforce_picklist_values()— não é erro.
Quando você só quer o agregado — um total, uma média, um mínimo/máximo ou uma
contagem — e não quer trazer as linhas para o DuckDB, a table function
salesforce_aggregate(catalog, object, aggregates [, filter [, group_by]])
pede o cálculo direto ao Salesforce e devolve uma única linha (ou, com
group_by, uma linha por grupo). Ela reutiliza a sessão
autenticada do catálogo que você já anexou (o primeiro argumento é o alias
do ATTACH, ex. 'sf'), então não há credenciais na chamada.
Isto é opt-in, não um pushdown transparente: é você quem escolhe pedir o
agregado server-side. O COUNT(*) continua sendo o único agregado com
pushdown automático em um SELECT normal (seção 6); os demais agregados você
pede explicitamente com esta função.
Todos os argumentos são literais VARCHAR. Cada termo de aggregates pode ter
um alias no estilo SOQL (separado por espaço, ex. MIN(AnnualRevenue) minRev).
A saída traz uma coluna por termo, todas VARCHAR, nomeadas pelo alias do
termo — ou expr0, expr1, ... quando o termo não tem alias. Como os valores
voltam como texto, faça o cast no DuckDB:
SELECT
CAST(minRev AS DECIMAL(18,2)) AS min_rev,
CAST(maxRev AS DECIMAL(18,2)) AS max_rev,
CAST(n AS BIGINT) AS n
FROM salesforce_aggregate(
'sf', 'Account',
'MIN(AnnualRevenue) minRev, MAX(AnnualRevenue) maxRev, COUNT(Id) n');O quarto argumento (opcional) é um corpo de WHERE SOQL sem a palavra
WHERE — útil para restringir o cálculo:
SELECT CAST(n AS BIGINT) AS n
FROM salesforce_aggregate(
'sf', 'Account',
'COUNT(Id) n',
'Industry = ''Technology''');O quinto argumento (opcional) é group_by: identifiers de campo simples
separados por vírgula (ex. Industry ou Industry, Type). Com ele, a função
monta SELECT <group_by>, <aggregates> FROM <object> [WHERE <filter>] GROUP BY <group_by> e devolve uma linha por grupo — as colunas de GROUP vêm
primeiro (nomeadas pelo campo), depois as de aggregate (alias ou expr0,
expr1, ...), tudo ainda VARCHAR. Como group_by é posicional depois de
filter, para agrupar sem filtro passe uma string vazia no quarto
argumento:
SELECT Industry, CAST(n AS BIGINT) AS account_count
FROM salesforce_aggregate('sf', 'Account', 'COUNT(Id) n', '', 'Industry')
ORDER BY account_count DESC;Para combinar filtro e agrupamento, preencha os dois (filter não-vazio + group_by):
SELECT Industry, CAST(n AS BIGINT) AS active_count
FROM salesforce_aggregate(
'sf', 'Account',
'COUNT(Id) n',
'AnnualRevenue > 1000000',
'Industry')
ORDER BY active_count DESC;Funções permitidas: MIN, MAX, SUM, AVG, COUNT e COUNT_DISTINCT. A
função honra sf_query_mode e registra o SOQL nos diagnósticos
(salesforce_last_soql(), salesforce_query_cost()). Limites a conhecer:
- Só termos de agregação. Campos "nus" (sem função de agregação) são rejeitados — é o que mantém o contrato de uma única linha.
GROUP BYsuportado pelo argumentogroup_by, mas só com campos simples; dotted/relationship fields, expressões eROLLUP/CUBE/HAVINGcontinuam fora de escopo e são rejeitados com erro claro.- O
objectdeve ser um identifier válido; agregados de relacionamento/polymorphic vão direto ao SOQL e qualquer erro do Salesforce aparece como veio.
Por padrão, um scan vê apenas os registros vivos. Para também incluir os
registros arquivados e os excluídos por soft delete que o Salesforce
ainda mantém, mude o modo de leitura da sessão com sf_query_mode:
SET sf_query_mode='queryAll';
SELECT Id, Name, IsDeleted
FROM sf.Account
LIMIT 10;- O padrão é
'query'(só registros vivos);'queryAll'lê pela capacidadequeryAlldo Salesforce, que devolve também arquivados e deletados. - Os deletados aparecem com
IsDeleted = true, então você pode filtrar ou separar esses registros com SQL normal. - O modo se aplica ao scan REST (endpoint
/queryAll), ao scan Bulk (job comoperation: "queryAll") e às sondagens deCOUNT()e deMIN(Id)/MAX(Id). Por isso, o COUNT pushdown, o transporteautoe as faixas de PK do Bulk chunking passam a refletir também os deletados e arquivados. - Um valor inválido gera um erro claro
(
sf_query_mode must be 'query' or 'queryAll').
Caveat: isto não é histórico, CDC nem replicação, e não é um snapshot
local — apenas expõe a capacidade de leitura do Salesforce naquele scan. A
utility salesforce_query() é sempre query e ignora sf_query_mode.
Uma deleção atualiza o SystemModstamp para o horário da exclusão, e a
linha deletada permanece visível via queryAll (IsDeleted = true) até a
purga da lixeira (~15 dias) — então uma carga por watermark rodando queryAll
dentro dessa janela captura os deletes no próprio watermark. O que a carga
por watermark não captura: a purga (lixeira esvaziada antes da próxima
carga) — daí a varredura periódica de deletados:
-- opção 1: varredura via queryAll (predicado server-side; as linhas ficam
-- varríveis por ~15 dias na lixeira)
SET sf_query_mode='queryAll';
SELECT Id FROM sf.Lead WHERE IsDeleted = true;
-- -> marque `is_deleted = true` (ou exclua) esses ids no destino e volte
-- sf_query_mode='query'
-- opção 2: janela exata pela API de replicação (getDeleted), com timestamp
-- de deleção (janelas de até 15 minutos por chamada)
SELECT id, deleted_date, source
FROM salesforce_deleted_ids('sf', 'Lead',
since := '2026-09-30T00:00:00', until := '2026-09-30T00:15:00');
-- horizonte maior: fallback queryAll, deleted_date NULL (retenção ~15 dias)
SELECT id FROM salesforce_deleted_ids('sf', 'Lead', since := '2026-09-20T00:00:00');Agende a varredura (ex.: diária): faça o upsert das linhas alteradas pela carga por watermark e depois marque/remova os ids que a varredura retornar.
salesforce_scan(catalog, object) faz um scan de um sObject usando as
credenciais de um catálogo anexado — útil para configuração por consulta sem
tocar nas settings de sessão (ex.: orquestradores que compartilham conexão):
-- predicado SOQL bruto anexado server-side (validado: sem ';', sem SELECT
-- aninhado, <= 4000 caracteres)
SELECT Name FROM salesforce_scan('sf', 'Lead', filter := 'Status = ''Open''')
LIMIT 10;
-- overrides de modo/transporte/chunks por chamada
SELECT count(*) FROM salesforce_scan('sf', 'Lead', query_mode := 'queryAll');
SELECT Id FROM salesforce_scan('sf', 'Account', transport := 'bulk', chunks := 4);Projeção e pushdown de predicados do plano funcionam exatamente como nas
tabelas de catálogo, e o SOQL gerado aparece em salesforce_last_soql().
Se uma carga morre no meio do stream de um job Bulk que já concluiu no
Salesforce, salesforce_bulk_resume(catalog, job_id) re-emite as linhas de
resultado sem reexecutar a consulta. As colunas vêm do header CSV do job
(VARCHAR):
SELECT Id, Name
FROM salesforce_bulk_resume('sf', '7504x00000ABCDef');Liga relatórios do Salesforce ao DuckDB para descoberta e validação — não extração grande. Um relatório é uma definição feita pelo negócio, não uma query SOQL, e a Reports API síncrona limita a 2.000 linhas. Três funções somente-leitura:
-- 1. descobrir definições de relatório (não dados)
SELECT Id, Name, Format FROM salesforce_reports('sf');
-- 2. rodar uma amostra TABULAR pequena (máx. 2000 linhas); colunas reservadas
-- __sf_report_* carregam diagnósticos (truncated / all_data / max_rows / guidance)
SELECT * FROM salesforce_report('sf', '00O...');
-- 3. ingredientes estruturados + um SOQL candidato best-effort para validar
SELECT soql, translatable, caveats FROM salesforce_report_soql('sf', '00O...');Fluxo pretendido: validar o relatório no Salesforce → rodar uma amostra
ground-truth com salesforce_report() → inspecionar nomes de campo + SOQL
candidato via salesforce_report_soql() → validar o candidato contra a amostra
→ depois materializar em escala via scans normais sf.<Object> (Bulk, PK
chunking, pushdown — seção 7).
Ressalvas principais:
salesforce_report()é amostra/oráculo, não caminho de extração. Verifique__sf_report_truncated. Um relatório com 0 linhas retorna 0 linhas e nenhuma linha de diagnóstico — a truncagem só é visível quando a amostra é não-vazia.salesforce_report_soql().soqlé candidato, não contrato de equivalência: só é produzido para relatórios tabulares de objeto único com identificadores seguros e operadores suportados; senãotranslatable = false,soql = NULL, ecaveatsexplicam o motivo. Sempre valide contra a amostra antes de escalar.- Relatórios summary/matrix não são suportados neste corte (apenas tabular).
Conheça estes limites antes de construir sobre a extensão:
- Somente-leitura. Toda escrita e DDL contra o catálogo Salesforce lança erro.
LIMITno Bulk não é server-side; o job Bulk roda por completo (o streaming lazy ainda interrompe o download das páginas seguintes com umLIMITpequeno).- auto não enxerga o
LIMITao sondar. Para uma consulta comLIMITpequeno em um objeto enorme, forcerest. - Relacionamentos são apenas de pai, profundidade até 2
(
sf_relationship_depth, do pai ao avô); lookups polimórficos são ignorados e predicados sobre subcampos são residuais. - A origem de schema tooling produz tipos mais grosseiros e reduz o pushdown.
- O pushdown de agregação é apenas para COUNT.
- Os PK chunks não têm ordem global e podem ser desiguais ou vazios.
- As plataformas validadas no CI são
linux_amd64+windows_amd64(baseline) eosx_arm64(extra);osx_amd64, outros ARM e wasm ainda não foram validados. TLS ao vivo no macOS não é verificado (useSSL_CERT_FILE). - Ainda não está no catálogo community, e o projeto é pré-1.0.