Por que escrevemos à mão um analisador PartiQL para DynamoDB
O DynamoDB aceita uma fatia estreita de e rejeita todo o resto no momento da requisição. GROUP BY? ValidationException. Um LIMIT no nível da instrução? ValidationException. O operador *, CAST, uma subconsulta? Todos eles são analisados sem problema na sua cabeça, trafegam pela rede e morrem no servidor. O único lugar onde esse conhecimento vivia era a documentação da AWS e as mensagens de erro, o que significava que todo editor para DynamoDB — inclusive o nosso, por um tempo — deixava você compor alegremente uma instrução que o mecanismo tinha garantia de recusar.
Queríamos que a recusa acontecesse no editor, a cada tecla digitada, com um sublinhado vermelho ondulado na cláusula exata e uma correção em um clique onde existe uma reescrita. Essa necessidade do editor virou um lexer e um analisador de CST escritos à mão para o dialeto PartiQL do DynamoDB, e nesta semana abrimos o código: dynamodb-partiql-parser, TypeScript puro, zero dependências, MIT, com a integração com o CodeMirror publicada separadamente como codemirror-lang-partiql. Este post é sobre por que ele é escrito à mão, o que o primeiro linter errou e os dois bugs que só apareceram quando alguém colou lixo.
Regex dava conta, até não dar mais
O primeiro linter de PartiQL no DynoTable tinha cerca de 650 linhas de regex e varredura de tokens, e era genuinamente útil: dezenove verificações distintas, correções rápidas para as armadilhas mais comuns (IN (...) para [...], LIKE para contains(), IS NULL para attribute_not_exists()). Foi lançado, pegou erros reais, os usuários pararam de abrir tickets de "por que minha consulta falha" nos casos que ele cobria.
Mas um linter de regex conhece padrões, não estrutura. Ele não conseguia enxergar que o * em SELECT price * quantity é aritmética que o DynamoDB rejeita, porque * também significa "todas as colunas" e distinguir os dois exige de fato analisar a sintaxe. Seus intervalos de diagnóstico eram aproximações — perto o bastante para apontar uma linha, grosseiros demais para alimentar uma correção rápida que emenda texto em posições exatas. E cada nova verificação deixava a pilha mais frágil, porque cada regex tinha que se defender das suposições de todas as outras.
A solução para "o linter precisa de estrutura" é um analisador. A questão era qual.
Ninguém tinha construído um
Para o lado do SQL de verdade do Workbench já tínhamos passado por isso: um analisador SQL pronto que mentiu para nós, substituído por sql-parser-cst, que carrega um intervalo de origem em cada nó e preserva identificadores com e sem aspas. Aquela experiência definiu o patamar do que o lado PartiQL precisava — uma árvore de sintaxe concreta sem perdas, não uma AST com perdas.
Mas PartiQL não é SQL justamente onde importa para um analisador. O dialeto do DynamoDB escreve listas IN com colchetes (WHERE OrderID IN [100, 300, 234]), tem literais de bag (<<'a', 'b'>>), literais de map com chaves entre aspas ({'rating': 5}), um literal MISSING, caminhos de documento com índices de lista (Devices.FireStick.DateWatched[0]) e RETURNING ALL OLD * — nada disso uma gramática SQL conhece. Na direção oposta, falta metade do que uma gramática SQL exige. Na época, os analisadores no npm eram builds WebAssembly da implementação em Rust da AWS para PartiQL genérico, sem nenhuma noção do que o DynamoDB especificamente rejeita.
Então escrevemos um: um lexer pequeno e um analisador descendente recursivo, modelado no formato que o sql-parser-cst nos ensinou a querer. Cada nó carrega seu intervalo de bytes. O conjunto todo tem zero dependências de runtime — uma propriedade que a CI agora verifica, porque é o que torna o analisador incorporável em qualquer lugar, inclusive no navegador, inclusive no seu projeto.
A gramática era a metade fácil. O analisador de um linter passa a vida inteira analisando código quebrado. No meio da digitação, metade de uma instrução, um erro de digitação na terceira cláusula. Parar no primeiro erro deixaria o editor inútil, então o analisador é tolerante a erros: ele registra um diagnóstico, se ressincroniza e segue em frente, de modo que a quarta cláusula continua sendo verificada enquanto a segunda está incompleta.
Trocar o motor sem derrubar o avião
Quando o analisador ficou pronto, as quatro funções do linter de regex já eram estruturais em todo o editor — inclusive a que decide se uma instrução pode ser executada automaticamente com segurança. Mudar esse comportamento em silêncio aparece como "o editor não roda minha consulta", que é o tipo de bug que os usuários não tanto relatam quanto abandonam.
Por isso a troca foi um strangler: o linter antigo foi renomeado, congelado e mantido no repositório. O novo linter, movido pelo analisador, reexportou exatamente as mesmas quatro funções. E um corpus de paridade rodou cada fixture pelos dois linters e fixou as saídas uma contra a outra — todo diagnóstico que a versão em regex produzia, a versão com analisador tinha que produzir também, antes de ter permissão para produzir mais. O linter antigo continua lá hoje, congelado, como documentação executável do que a troca prometeu.
Os bugs que só o lixo encontra
Duas falhas nunca apareceram em nenhuma consulta real e as duas teriam derrubado o editor.
Um linter do CodeMirror roda de forma síncrona sobre o documento, a cada mudança, sem nenhum coletor de erros acima dele. Uma exceção não capturada não faz a verificação falhar — ela deixa o editor em tela branca. E um analisador descendente recursivo traz uma exceção não capturada embutida por natureza: a pilha de chamadas. Cole [[[[[[… com alguns milhares de colchetes de profundidade, ou uma cadeia NOT NOT NOT …, e cada nível de aninhamento é um quadro de pilha; o V8 acaba lançando RangeError: Maximum call stack size exceeded direto através do linter.
As correções são entediantes de propósito. A recursão de expressões tem um teto rígido de profundidade — quinhentos níveis, muito além de qualquer coisa que um humano escreva, bem abaixo do orçamento da pilha — a partir do qual o analisador emite um único diagnóstico em vez de lançar exceção. E as construções em que colagens realmente encadeiam, como A UNION B UNION C … com milhares de braços, foram reescritas de recursão para listas planas: um quadro de parseSelect e um array de operações de conjunto, em vez de um quadro por braço. A suíte de estresse agora cola 100 KB de lixo e cadeias de operadores com 30 mil níveis de profundidade a cada build, e o pacote público envolve o pipeline inteiro em um ponto de entrada lint() que nunca lança exceção, porque o próximo editor a incorporar isso terá o mesmo problema de ausência de coletor de erros que tivemos.
Uma suíte de testes que você pode auditar contra a documentação da AWS
As regras do dialeto — o que o DynamoDB aceita, o que rejeita, qual reescrita corrige o quê — vêm todas da referência de PartiQL da AWS. Comportamento derivado de documentação tem um modo de falha específico: o documento muda, o código não, e ninguém percebe.
Por isso o corpus é estruturado em cima dela. Duzentas e oito fixtures, e cada uma abre com a URL da página da documentação da AWS de onde a regra vem. Uma tabela de cobertura mapeia cada regra documentada para sua fixture, e a suíte falha se uma regra perde a sua. Quando a AWS muda o dialeto, o diff é um diff de fixture com uma citação junto.
Essa disciplina se pagou na semana em que abrimos o código. O aviso de lista IN do linter citava dois limites: 50 valores em uma coluna de chave de partição, 100 em uma coluna que não é chave. Ao reverificar cada número antes da publicação, conseguimos confirmar o 100 na documentação atual da AWS — e não conseguimos encontrar o 50 em nenhum documento vigente. Ele sobrevive espalhado por posts de blog e respostas antigas de fórum, mas a fonte primária seguiu em frente. O linter estava certo por acidente (ele só avisa acima de 100, já que sem o seu esquema não dá para saber qual caso se aplica), e o comentário agora diz exatamente qual metade da afirmação é documentada e qual é folclore.
O que se aproveita se você for construir um
- Um analisador descendente recursivo escrito à mão para um dialeto pequeno é trabalho de dias, não de meses, e você é dono de cada mensagem de erro. A versão assustadora de "escreva um analisador" pressupõe uma gramática grande.
- Construa uma CST, não uma AST. Intervalos de bytes em cada nó são o que transforma diagnósticos em correções rápidas; uma árvore com perdas não consegue emendar texto.
- Se o analisador alimenta um linter, a tolerância a erros é o recurso. Recupere e continue; um analisador que para no primeiro erro não verifica mais nada depois dele.
- Troque mecanismos atrás de uma interface congelada, com um corpus de paridade fixando o antigo contra o novo. A implementação antiga é a especificação com a qual você já concordou.
- Em qualquer lugar onde a entrada pode aninhar, alguém vai colar algo que aninha de forma absurda. Limite a profundidade da recursão e achate as cadeias; teste com lixo, não só com consultas.
- Cite suas fontes nos testes. Uma fixture que nomeia a página de documentação que ela codifica é um teste que pode ser auditado quando o documento mudar — e ele vai mudar.
O analisador está no GitHub e no npm (npm install dynamodb-partiql-parser), com a integração com o editor em codemirror-lang-partiql. Se você quer o dialeto em si em vez do analisador, PartiQL vs SQL cobre o que o subconjunto do DynamoDB pode e não pode fazer e exemplos de PartiQL é o passo a passo prático; o editor para o qual tudo isso foi construído está no DynoTable, e você pode testar de graça.