· 8 min de leitura

Como construímos um mecanismo SQL local para o DynamoDB (um banco de dados sem JOINs)

O DynamoDB consegue exportar uma tabela de 100 GB, mas não consegue agregá-la. Não há JOIN, não há GROUP BY, não há COUNT(*) — não versões lentas deles, nenhum — e mesmo o , a própria superfície SQL-esca da AWS, não os adiciona. Então toda equipe acaba escrevendo o mesmo script descartável: paginar um , dobrar as linhas em um mapa, imprimir a resposta, deletar o script.

O Workbench do DynoTable é nossa resposta a esse script: uma aba onde você escreve um SELECT de verdade — JOIN … ON multi-tabela, WHERE, GROUP BY, HAVING, DISTINCT, CASE, agregados — contra tabelas que não suportam nada disso. Este post é sobre como ele funciona por baixo dos panos, o parser que mentiu silenciosamente para nós, e por que depois arrancamos o modelo de memória do mecanismo.

Não construa um mecanismo de query

A decisão central foi recusar escrever um executor relacional. As páginas do DynamoDB fazem streaming para dentro de um banco de dados SQLite embarcado, e o SQLite — um dos mecanismos de query mais testados do planeta — faz o trabalho relacional:

which tables,which join keysrewritten SQLYour SQLCompilersql-parser-cstDynamoDBpage streamLocal SQLiteon diskResult grid

Cada item do DynamoDB cai no SQLite como seu envelope tipado bruto ({platform: {S: "ios"}}) em uma única coluna item. Uma pequena função definida pelo usuário desembrulha os atributos em tempo de query, então seu SELECT platform compila para uma chamada attr(item, 'platform', …). Um detalhe nessa função é estrutural: ela envolve seu parsing de JSON em um catch, porque o binding do SQLite transforma o erro de parse de uma linha malformada em um abort da instrução preparada inteira. Sem o catch, uma linha corrompida mataria a query.

A restrição honesta que mantivemos: a física do padrão de acesso do DynamoDB ainda se aplica. O lado de destino de um JOIN precisa ser uma chave primária ou uma chave de partição de — o mecanismo resolve joins por lookup de chave contra o stream, nunca por scan de produto cartesiano. Se você precisa de joins arbitrários em atributos que não são chave, isso é uma conversa de modelagem, não um recurso do mecanismo de query.

O Workbench: um JOIN SQL multi-tabela, com a grade de resultado unido abaixo.
O Workbench: um JOIN SQL multi-tabela, com a grade de resultado unido abaixo.

O parser que mentiu para nós

A primeira versão usava um parser SQL popular de prateleira. Ao longo de algumas semanas, quatro bugs de correção independentes foram rastreados até ele:

  • SELECT DISTINCT era descartado silenciosamente. O parser o aceitava e o emissor o ignorava — toda linha duplicada voltava. O relato de bug de um usuário dizia: "mostra uma tabela de todos os valores, não contadores."
  • Qualquer coisa não tratada virava o NULL literal do SQL. CASE, CAST, subqueries escalares — o fallback do emissor para um nó de sintaxe desconhecido era return 'NULL'. As queries rodavam, retornavam respostas erradas com confiança e não levantavam nenhum erro.
  • O case dos identificadores não se comportava como SQL. SELECT PLATFORM FROM t retornava nulos quando o atributo era platform — o oposto do que todo banco de dados SQL te ensina a esperar.
  • O autocompletar discordava do resolver, sugerindo nomes que o compilador então falharia em resolver.

O substituto, sql-parser-cst, venceu por uma primitiva que a pesquisa não encontrou em nenhum outro lugar: sua árvore preserva tanto o texto bruto quanto o nome normalizado de cada identificador — então o compilador consegue distinguir platform de "PLATFORM". Essa única distinção permite que uma gramática carregue a semântica ANSI adequada: identificadores sem aspas resolvem de forma insensível a maiúsculas/minúsculas, os com aspas são a saída de emergência sensível a maiúsculas/minúsculas, exatamente como o Postgres ou o SQLite. Ela também carrega um intervalo de origem em cada nó, então um diagnóstico sublinha a construção problemática em vez da instrução inteira.

E substituímos o fallback return 'NULL' por uma regra que agora tratamos como política: nenhum NULL silencioso, nunca. O braço padrão do emissor é um erro com intervalo preciso. Funções de janela recebem uma mensagem que diz por quê, não apenas não:

Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.

A troca que aceitamos: uma dependência pré-1.0, fixada na versão exata, atualizada apenas manualmente com a suíte de testes como gate. Avaliamos corrigir o parser antigo (ele precisava de um pré-tokenizador paralelo — fazendo parse de cada entrada duas vezes), usar a árvore de sintaxe do editor (a nível de token, sem estrutura de cláusula) e escrever à mão (a maior parte de um parser SQLite, manutenção ilimitada). Uma falha de correção em quatro frentes supera todas essas preocupações.

Saber o que não bloquear

Um diagnóstico deliberadamente não te impede. Quando um filtro referencia um atributo cujo case o schema não consegue confirmar, não temos como saber o case canônico — os filtros do lado do servidor do DynamoDB são sensíveis a maiúsculas/minúsculas e seu schema só declara atributos de chave. Isso emite um aviso, e avisos nunca desativam o Run. Aprendemos essa classe de lição do jeito difícil no nosso trabalho com validadores: um gate que rejeita o formato de query legítimo mais comum é pior que nenhum gate.

O limite tinha o formato errado

O primeiro mecanismo de agregação bufferizava as linhas escaneadas em um SQLite em memória com um limite de tamanho — 64 MB aqui, 250 MB ali, e um caminho sem limite que podia estourar a memória (OOM) do app em uma tabela grande. Atinja o limite e você recebia uma resposta parcial com uma flag de overflow.

O redesign começou com um reenquadramento: o concorrente é o script descartável que um desenvolvedor escreveria no lugar. E um script competente não bufferiza-e-limita — ele faz streaming, dobrando cada página em um total corrente com memória limitada. O limite em memória não era pequeno demais; ele tinha o formato errado. Pior, um agregado parcial não é "incompleto" — ele é errado. O momento que tornou isso concreto: nossa própria demo de IA narrou com confiança um ranking parcial de "maiores gastadores" como fato.

O mecanismo Compute reconstruído funciona do jeito que o script funciona, mais tudo aquilo com que o script nunca se importa:

  • Um planejador classifica cada query em uma de três vias: stream-fold (agregados algébricos simples — COUNT/SUM/AVG/MIN/MAX — dobrados página por página fora do processo principal; o tamanho nunca barra uma query dobrável, só a deixa mais demorada), materialize (ordenações, joins limitados por chave — despejados em SQLite apoiado em disco, sem limite de memória), ou uma recusa honesta que recomenda a ferramenta pesada certa (a exportação para S3 do DynamoDB mais o Athena) para a cauda que não conseguimos atender.
  • A classificação de fold é deliberadamente estreita. Um HAVING, um ORDER BY, uma coluna solta ao lado do agregado — qualquer um deles força a via materialize. Um fold que ignorasse uma cláusula e ainda reportasse "exato" seria precisamente o bug errado-mas-confiante que este planejador existe para evitar.
  • A exatidão faz parte do resultado. As colunas de agregado mostram um badge de parcial enquanto as páginas ainda estão em streaming, e só o largam quando o scan se esgota. Até a honestidade aritmética é rastreada: somas de inteiros permanecem exatas além de 2⁵³ (armazenadas como inteiros de 64 bits), enquanto uma soma fracionária que excede a mantissa do float se sinaliza como partial: precision em vez de arredondar silenciosamente.
  • Fusíveis substituem popups para máquinas. O app interativo pergunta antes de um scan de cinco milhões de itens; o assistente de IA e os chamadores MCP recebem, em vez disso, orçamentos de scan e aborts em fronteiras de página. E o envelope de resultado que o modelo vê é deliberadamente enxugado — a classe de custo e as estimativas de tamanho são omitidas, porque expor um palpite desatualizado convida o modelo a narrá-lo como fato.

Uma nota de custo que surpreendeu as pessoas internamente: para um agregado pontual, um scan ao vivo é cerca de 6× mais barato que gerar uma exportação DynamoDB→S3 — e é por isso que a via em formato de script é o padrão e o data warehouse é a recomendação para analytics pesado e repetido, não o reflexo. (A calculadora de preços vai precificar uma passada completa da sua própria tabela.)

É seguro apontar SQL para produção?

O Workbench é estruturalmente somente leitura. O SQL final que chega ao mecanismo local passa por um gate de defesa em profundidade que tokeniza e rejeita qualquer coisa que não seja um único SELECT — como uma violação de contrato, não um erro de usuário, porque o compilador nunca deveria tê-lo produzido. O substrato SQLite roda com os built-ins perigosos desativados, e as escritas no DynamoDB têm exatamente um caminho no app inteiro: a área de staging revisável, que o SQL não consegue alcançar.

O que se transfere se você está construindo um

  • Não escreva um mecanismo de query; escreva um compilador fiel para um que já existe. Seus bugs vão viver nas junções (case de identificador, envelopes de tipo, linhas corrompidas), não no algoritmo de join.
  • Exija proveniência de aspas do seu parser. Se ele não consegue distinguir name de "NAME", você não consegue implementar a semântica de case do SQL, e seus usuários vão descobrir antes de você.
  • Faça da "sintaxe não tratada" um erro barulhento, nunca um valor padrão. NULL silencioso é como respostas erradas e confiantes são entregues.
  • Compare seu design com o script descartável que seu usuário escreveria. Se o script faz streaming e você bufferiza, você construiu o formato errado.
  • Trate agregados parciais como respostas erradas que precisam de rótulo, não como crédito parcial.

A documentação do Workbench cobre a superfície de recursos do dia a dia, e SQL para DynamoDB mapeia o que funciona em todo o ecossistema. Ou simplesmente baixe o DynoTable, abra uma aba do Workbench com ⌘⌥Q, e rode um JOIN contra o banco de dados que não tem um.

Trabalhe com o DynamoDB sem o Console

O DynoTable é um cliente desktop rápido para o DynamoDB — navegue pelas tabelas, execute consultas no estilo SQL e edite itens localmente.