· 7 min de lecture

Pourquoi nous avons écrit à la main un parser PartiQL pour DynamoDB

DynamoDB accepte une part étroite de et rejette tout le reste au moment de la requête. GROUP BY ? ValidationException. Un LIMIT au niveau de l’instruction ? ValidationException. L’opérateur *, CAST, une sous-requête ? Tous se parsent très bien dans ta tête, voyagent sur le réseau, et meurent sur le serveur. Le seul endroit où ce savoir vivait, c’était la documentation AWS et les messages d’erreur, ce qui voulait dire que chaque éditeur pour DynamoDB — y compris le nôtre, pendant un temps — te laissait joyeusement composer une instruction que le moteur était garanti de refuser.

Nous voulions que le refus arrive dans l’éditeur, à la frappe, avec un soulignement rouge sur la clause exacte et une correction en un clic là où une réécriture existe. Ce besoin d’éditeur s’est transformé en un lexer et un parser CST écrits à la main pour le dialecte PartiQL de DynamoDB, et cette semaine nous l’avons passé en open source : dynamodb-partiql-parser, TypeScript pur, zéro dépendance, MIT, avec le câblage CodeMirror publié séparément sous codemirror-lang-partiql. Cet article, c’est pourquoi il est écrit à la main, ce que le premier linter a raté, et les deux bugs qui ne sont apparus que quand quelqu’un a collé du n’importe quoi.

Les regex suffisaient, jusqu’à ce qu’elles ne suffisent plus

Le premier linter PartiQL de DynoTable, c’était environ 650 lignes de regex et de scan de tokens, et il était réellement utile : dix-neuf vérifications distinctes, des quick fixes pour les pièges courants (IN (...) vers [...], LIKE vers contains(), IS NULL vers attribute_not_exists()). Il a shipé, il attrapait de vraies erreurs, les utilisateurs ont arrêté d’ouvrir des tickets « pourquoi ma requête échoue » pour les cas qu’il couvrait.

Mais un linter à base de regex connaît des motifs, pas de la structure. Il ne pouvait pas voir que le * dans SELECT price * quantity est une arithmétique que DynamoDB rejette, parce que * veut aussi dire « toutes les colonnes » et que les distinguer demande de parser pour de vrai. Ses plages de diagnostic étaient des approximations — assez proches pour pointer une ligne, trop grossières pour piloter un quick fix qui insère du texte à des offsets exacts. Et chaque nouvelle vérification rendait le tas plus fragile, parce que chaque regex devait se défendre contre les hypothèses de toutes les autres.

La solution à « le linter a besoin de structure », c’est un parser. La question, c’était lequel.

Personne n’en avait construit un

Pour le côté vrai-SQL du Workbench, nous étions déjà passés par là : un parser SQL off-the-shelf qui nous a menti, remplacé par sql-parser-cst, qui porte une plage source sur chaque nœud et préserve les identifiants quotés versus non quotés. Cette expérience a fixé la barre de ce dont le côté PartiQL avait besoin — un arbre de syntaxe concret sans perte, pas un AST à perte.

Mais PartiQL n’est pas du SQL là où ça compte pour un parser. Le dialecte de DynamoDB écrit les listes IN avec des crochets (WHERE OrderID IN [100, 300, 234]), a des littéraux bag (<<'a', 'b'>>), des littéraux map à clés quotées ({'rating': 5}), un littéral MISSING, des chemins de document avec index de liste (Devices.FireStick.DateWatched[0]), et RETURNING ALL OLD * — rien de tout ça n’est connu d’une grammaire SQL. Dans l’autre sens, il lui manque la moitié de ce qu’une grammaire SQL exige. À l’époque, les parsers sur npm étaient des builds WebAssembly de l’implémentation Rust d’AWS pour PartiQL générique, sans aucune notion de ce que DynamoDB rejette spécifiquement.

Nous en avons donc écrit un : un petit lexer et un parser à descente récursive, modelés sur la forme que sql-parser-cst nous avait appris à vouloir. Chaque nœud porte sa plage d’octets. L’ensemble a zéro dépendance runtime — une propriété que la CI vérifie désormais, parce que c’est ce qui rend le parser embarquable n’importe où, y compris dans le navigateur, y compris dans ton projet.

La grammaire était la moitié facile. Le parser d’un linter passe sa vie entière à parser du code cassé. En plein milieu d’une frappe, une demi-instruction, une faute de frappe dans la troisième clause. S’arrêter à la première erreur rendrait l’éditeur inutile, donc le parser est tolérant aux erreurs : il enregistre un diagnostic, se resynchronise, et continue, pour que la quatrième clause soit encore lintée pendant que la deuxième est incomplète.

Changer le moteur sans casser l’avion

Quand le parser a été prêt, les quatre fonctions du linter à regex étaient porteuses dans tout l’éditeur — y compris celle qui décide si une instruction peut être auto-exécutée sans risque. Changer ce comportement en silence, ça se voit comme « l’éditeur ne veut pas lancer ma requête », le genre de bug que les utilisateurs rapportent moins qu’ils ne s’en vont à cause de lui.

Le remplacement a donc été un strangler : l’ancien linter a été renommé, gelé, et conservé dans l’arbre. Le nouveau linter piloté par le parser ré-exportait exactement les quatre mêmes fonctions. Et un corpus de parité faisait passer chaque fixture dans les deux linters et pinnait les sorties l’une contre l’autre — chaque diagnostic que produisait la version regex, la version parser devait le produire aussi, avant d’avoir le droit d’en produire plus. L’ancien linter est encore là aujourd’hui, gelé, comme documentation exécutable de ce que le remplacement a promis.

Les bugs que seul le n’importe quoi trouve

Deux défaillances ne sont jamais apparues dans une vraie requête et toutes les deux auraient mis l’éditeur à terre.

Un linter CodeMirror tourne de façon synchrone sur le document, à chaque changement, sans aucun collecteur d’erreurs au-dessus de lui. Une exception non attrapée ne fait pas échouer un lint — elle met l’éditeur en écran blanc. Et un parser à descente récursive embarque une exception non attrapée par nature : la pile d’appels. Colle [[[[[[… sur quelques milliers de crochets de profondeur, ou une chaîne NOT NOT NOT …, et chaque niveau d’imbrication est une frame de pile ; V8 finit par lever RangeError: Maximum call stack size exceeded droit à travers le linter.

Les correctifs sont ennuyeux exprès. La récursion d’expressions a un plafond de profondeur dur — cinq cents niveaux, bien au-delà de ce qu’un humain écrit, bien en dessous du budget de pile — passé lequel le parser émet un seul diagnostic au lieu de lever. Et les constructs où un collage s’enchaîne réellement, comme A UNION B UNION C … sur des milliers de bras, ont été réécrits de la récursion vers des listes plates : une frame parseSelect et un tableau d’opérations ensemblistes, au lieu d’une frame par bras. La suite de stress colle désormais 100 Ko de n’importe quoi et des chaînes d’opérateurs profondes de 30 000 à chaque build, et le package public enveloppe tout le pipeline dans un point d’entrée lint() qui ne lève jamais, parce que le prochain éditeur qui embarquera ça aura le même problème d’absence de collecteur d’erreurs que nous.

Une suite de tests que tu peux auditer contre la doc d’AWS

Les règles du dialecte — ce que DynamoDB accepte, ce qu’il rejette, quelle réécriture corrige quoi — viennent toutes de la référence PartiQL d’AWS. Un comportement dérivé de la documentation a un mode de défaillance spécifique : la doc bouge, le code non, et personne ne le remarque.

Le corpus est donc structuré contre elle. Deux cent huit fixtures, et chacune s’ouvre sur l’URL de la page de documentation AWS d’où vient la règle. Une table de couverture mappe chaque règle documentée vers sa fixture, et la suite échoue si une règle perd sa fixture. Quand AWS change le dialecte, le diff est un diff de fixture avec une citation dessus.

Cette discipline s’est remboursée la semaine où nous avons ouvert le code. L’avertissement du linter sur les listes IN citait deux plafonds : 50 valeurs sur une colonne de clé de partition, 100 sur une colonne non-clé. En revérifiant chaque nombre avant publication, nous avons pu confirmer le 100 dans la documentation actuelle d’AWS — et nous n’avons trouvé le 50 nulle part dans une doc en vigueur. Il survit un peu partout dans des articles de blog et de vieilles réponses de forum, mais la source primaire est passée à autre chose. Le linter avait raison par accident (il n’avertit qu’au-delà de 100, puisque sans ton schéma il ne peut pas dire quel cas s’applique), et le commentaire dit maintenant exactement quelle moitié de l’affirmation est documentée et laquelle est du folklore.

Ce qui se transfère si tu en construis un

  • Un parser à descente récursive écrit à la main pour un petit dialecte, c’est des jours de travail, pas des mois, et tu possèdes chaque message d’erreur. La version effrayante d’« écris un parser » suppose une grosse grammaire.
  • Construis un CST, pas un AST. Les plages d’octets sur chaque nœud sont ce qui transforme des diagnostics en quick fixes ; un arbre à perte ne peut pas insérer de texte.
  • Si le parser alimente un linter, la tolérance aux erreurs est la feature. Récupère et continue ; un parser qui s’arrête à la première erreur ne linte rien après elle.
  • Change de moteur derrière une interface gelée, avec un corpus de parité qui pinne l’ancien contre le nouveau. L’ancienne implémentation est la spec que tu as déjà acceptée.
  • Partout où l’entrée peut s’imbriquer, quelqu’un collera quelque chose qui s’imbrique absurdement. Plafonne la profondeur de la récursion et aplatis les chaînes ; teste avec du n’importe quoi, pas seulement avec des requêtes.
  • Cite tes sources dans les tests. Une fixture qui nomme la page de doc qu’elle encode est un test qui peut être audité quand la doc change — et elle changera.

Le parser est sur GitHub et sur npm (npm install dynamodb-partiql-parser), avec l’intégration éditeur dans codemirror-lang-partiql. Si c’est le dialecte lui-même qui t’intéresse plutôt que le parser, PartiQL vs SQL couvre ce que le sous-ensemble de DynamoDB peut et ne peut pas faire et les exemples PartiQL sont la présentation pratique ; l’éditeur pour lequel tout ça a été construit est dans DynoTable, et tu peux l’essayer gratuitement.

Travaille avec DynamoDB sans la Console

Un client de bureau rapide pour DynamoDB qui exécute le vrai SQL que DynamoDB ne peut pas — JOINs, GROUP BY, agrégations — avec édition visuelle et un agent IA sur tes propres clés Bedrock.

Essai gratuit de 30 jours, sans carte bancaire — ensuite la formule Gratuit, sans limite de durée.