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.