Skip to content

Latest commit

 

History

History
1203 lines (952 loc) · 48.8 KB

usage_guide.md

File metadata and controls

1203 lines (952 loc) · 48.8 KB

duckdb-salesforce - Guia de uso para analistas

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.

Referências oficiais DuckDB

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 SELECT materializa uma consulta Salesforce em uma tabela local do DuckDB.
  • Table functions: esta extensão fornece funções de diagnóstico como salesforce_query_cost().

1. Conceitos antes de começar

Catálogo somente-leitura

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).

Tabelas são sObjects

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.

Autenticação e segurança

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.

2. Instalação e carregamento

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.

macOS: bundle de certificados TLS

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 Python

Um 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.

3. Conectar ao Salesforce

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.

3.1 Fontes de autenticação

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 do ATTACH: client_id, client_secret e refresh_token (obrigatórios), mais os opcionais login_url e api_version.
  • 'env' — credenciais nas variáveis de ambiente SF_CLIENT_ID, SF_CLIENT_SECRET e SF_REFRESH_TOKEN (obrigatórias), mais a opcional SF_LOGIN_URL.
  • 'sfdx_url' — credenciais na variável SF_SFDX_AUTH_URL, no formato force://<clientId>:<clientSecret>:<refreshToken>@<host-da-instancia> (o clientSecret pode 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 um 401, re-assina um novo assertion. Exige client_id (o issuer), username (o subject — o usuário Salesforce a personificar) e uma chave privada, cujo caminho vem da variável de ambiente SF_JWT_KEY_FILE (recomendado) ou da opção inline private_key_file (apenas dev local). A chave deve ser PEM RSA PKCS#1 ou PKCS#8 não criptografada. login_url é opcional (padrão https://login.salesforce.com; use https://test.salesforce.com para 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).

Contexto 1 — Terminal

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
duckdb
ATTACH '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)
duckdb
ATTACH '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
duckdb
ATTACH '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'
);

Contexto 2 — Web UI / backend

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'
);

Contexto 3 — Python / pipeline

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')"
)

Nota de segurança

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".

Fora de escopo (neste corte)

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.

4. Primeiras consultas

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 e Custom Settings

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 __mdt ou um novo campo durante a sessão, chame salesforce_refresh_metadata('<catalog>') (ou salesforce_refresh_metadata('<catalog>', '<Object>')) para que a próxima referência re-descreva o schema.

5. Materializar resultados localmente

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.

6. Transportes: REST, Bulk e auto

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 em sf_auto_bulk_threshold (padrão 50000). A sondagem é controlada por sf_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;

Pushdown de COUNT

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;

Bulk e campos blob/base64

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 campo base64 projetado, 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 como base64 no 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.

REST e campos BODY blob/base64 (URL reference)

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.

Quadro combinado por transporte (campos BODY blob/base64)

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.

Campos datetime, date e time (ISO 8601 e a limitação de epoch)

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 / TIME independentemente 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+0000 vira o TIMESTAMP 2024-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 campo datetime/date/time nã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, como field '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.

Retries e timeout de leitura

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.

7. Extrações grandes: Bulk + PK chunking

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.

Recipe de seed e backfill para datalake

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 via salesforce_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.

8. Governança de quota

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.

9. Relacionamentos (opt-in)

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_depth para 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'.

Traversal de avô (profundidade 2)

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.Name no WHERE) continuam residuais, sem pushdown.
  • O over-fetch cresce com a profundidade: projetar o STRUCT busca todos os campos escalares de cada nível expandido.

10. Origem do schema

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 FieldDefinition em 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.

11. Diagnósticos

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 buscadas

Um 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.

Navegar pelos metadados (objetos e campos)

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).

Explicar um scan campo a campo

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=true com reason='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 (predicado OR/NOT/cross-field, field_name NULL) e unsupported_operator também são residuais.
  • Linhas projection — uma por campo selecionado (reason='projected'); um campo de referência também mostra relationship_name + reference_to.
  • Linhas relationship — uma por relacionamento pai realmente atravessado (reason='relationship_traversed').
  • Linha count — count_pushdown (servida por SELECT COUNT(), sem buscar registros) ou count_not_pushed.
  • Linha transport — transport_rest ou transport_bulk; a coluna guidance carrega o motivo, queryAll e est_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 para reason='metadata_unavailable' com resolved=false; as linhas count/transport continuam aparecendo. A função nunca dá erro.
  • LIMIT não aparece. Neste build do DuckDB a table function nunca recebe o LIMIT da 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.

Diagnosticar a expansão de relacionamentos

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: traz relationships_mode (off / parent), o relationship_depth efetivo (1..2) e os contadores expanded_count / skipped_count. Essa linha é emitida mesmo com sf_relationships = 'off' — nesse caso você recebe só a linha config, então "off" nunca parece um resultado vazio ou quebrado.
  • Cada linha row_type = relationship é um campo reference considerado. Quando status = expanded, a coluna field_count mostra quantos campos o STRUCT do pai tem e a note avisa do over-fetch: o pai é buscado inteiro (todos os campos escalares queryable), pois a projeção aninhada não recebe pushdown — selecionar Account.Name ainda busca o Account completo.
  • Quando status = skipped, a coluna reason diz o porquê (polymorphic, self_reference, cycle, name_collision, parent_not_describable, no_fields ou no_relationship_name). É assim que você descobre por que uma coluna de relacionamento esperada não apareceu.
  • A coluna depth_level distingue 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).

Atualizar metadados em sessão longa (salesforce_refresh_metadata)

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 object o refresh é cirúrgico: só o schema daquele objeto é descartado; os demais objetos e o object listing continuam em cache.
  • Sem object o 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.

Valores de picklist e record types

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). Use salesforce_refresh_metadata('<catalog>') ou salesforce_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.

Agregados server-side explícitos (salesforce_aggregate)

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 BY suportado pelo argumento group_by, mas só com campos simples; dotted/relationship fields, expressões e ROLLUP / CUBE / HAVING continuam fora de escopo e são rejeitados com erro claro.
  • O object deve ser um identifier válido; agregados de relacionamento/polymorphic vão direto ao SOQL e qualquer erro do Salesforce aparece como veio.

12. Lendo registros arquivados e excluídos (queryAll)

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 capacidade queryAll do 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 com operation: "queryAll") e às sondagens de COUNT() e de MIN(Id) / MAX(Id). Por isso, o COUNT pushdown, o transporte auto e 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.

12.1 O padrão de delete-sync para cargas incrementais

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.

12.2 Scans diretos sem a camada de catálogo

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().

12.3 Retomando um job Bulk concluído

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');

13. Report Bridge (opt-in)

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ão translatable = false, soql = NULL, e caveats explicam o motivo. Sempre valide contra a amostra antes de escalar.
  • Relatórios summary/matrix não são suportados neste corte (apenas tabular).

14. Limitações

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.
  • LIMIT no Bulk não é server-side; o job Bulk roda por completo (o streaming lazy ainda interrompe o download das páginas seguintes com um LIMIT pequeno).
  • auto não enxerga o LIMIT ao sondar. Para uma consulta com LIMIT pequeno em um objeto enorme, force rest.
  • 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) e osx_arm64 (extra); osx_amd64, outros ARM e wasm ainda não foram validados. TLS ao vivo no macOS não é verificado (use SSL_CERT_FILE).
  • Ainda não está no catálogo community, e o projeto é pré-1.0.