Warum wir einen PartiQL-Parser für DynamoDB von Hand geschrieben haben
DynamoDB akzeptiert eine schmale Scheibe von und lehnt alles
andere zur Request-Zeit ab. GROUP BY? ValidationException. Ein LIMIT auf
Statement-Ebene? ValidationException. Der *-Operator, CAST, eine Subquery?
Die parsen alle sauber in deinem Kopf, reisen über die Leitung und sterben auf
dem Server. Der einzige Ort, an dem dieses Wissen lebte, waren die AWS-
Dokumentation und die Error Messages — was hieß, dass jeder Editor für DynamoDB,
unserer eine Zeit lang eingeschlossen, dich bereitwillig ein Statement
komponieren ließ, das die Engine garantiert ablehnen würde.
Wir wollten, dass die Ablehnung im Editor passiert, auf den Tastendruck, mit einer roten Wellenlinie auf genau der Clause und einem Ein-Klick-Fix dort, wo es ein Rewrite gibt. Aus diesem Editor-Bedarf wurden ein handgeschriebener Lexer und CST-Parser für DynamoDBs PartiQL-Dialekt, und diese Woche haben wir ihn Open Source gestellt: dynamodb-partiql-parser, pures TypeScript, null Dependencies, MIT, mit der CodeMirror-Verdrahtung separat veröffentlicht als codemirror-lang-partiql. Dieser Post ist, warum er handgeschrieben ist, was der erste Linter falsch gemacht hat, und die zwei Bugs, die erst auftauchten, als jemand Müll hineinpastete.
Regex war gut genug, bis es das nicht mehr war
Der erste PartiQL-Linter in DynoTable war rund 650 Zeilen Regex und Token-
Scanning, und er war ehrlich nützlich: neunzehn distinkte Checks, Quick Fixes für
die häufigen Fallen (IN (...) zu [...], LIKE zu contains(), IS NULL zu
attribute_not_exists()). Er shippte, er fing echte Fehler, User hörten auf,
„warum failt meine Query“-Tickets für die Fälle zu stellen, die er abdeckte.
Aber ein Regex-Linter kennt Patterns, keine Struktur. Er konnte nicht sehen, dass
das * in SELECT price * quantity Arithmetik ist, die DynamoDB ablehnt, weil
* auch „alle Spalten“ bedeutet und die beiden auseinanderzuhalten echtes Parsing
verlangt. Seine Diagnostic Ranges waren Approximationen — nah genug, um auf eine
Zeile zu zeigen, zu grob, um einen Quick Fix zu treiben, der Text an exakten
Offsets spleißt. Und jeder neue Check machte den Haufen fragiler, weil jede Regex
sich gegen die Annahmen jeder anderen Regex verteidigen musste.
Der Fix für „der Linter braucht Struktur“ ist ein Parser. Die Frage war, welcher.
Niemand hatte einen gebaut
Für die echte SQL-Seite des Workbench hatten wir das schon
durchgemacht: ein Off-the-Shelf-SQL-Parser,
der uns belogen hat, ersetzt durch sql-parser-cst, der auf jedem Node eine
Source Range trägt und quoted-versus-unquoted Identifiers bewahrt. Diese
Erfahrung setzte die Messlatte für das, was die PartiQL-Seite brauchte — einen
verlustfreien Concrete Syntax Tree, keinen verlustbehafteten AST.
Aber PartiQL ist dort, wo es für einen Parser zählt, kein SQL. DynamoDBs Dialekt
schreibt IN-Listen mit Brackets (WHERE OrderID IN [100, 300, 234]), hat
Bag-Literale (<<'a', 'b'>>), Map-Literale mit gequoteten Keys ({'rating': 5}),
ein MISSING-Literal, Document Paths mit List-Indexes
(Devices.FireStick.DateWatched[0]) und RETURNING ALL OLD * — nichts davon
kennt eine SQL-Grammatik. In die andere Richtung fehlt ihm die Hälfte dessen, was
eine SQL-Grammatik einfordert. Die Parser auf npm waren zu dem Zeitpunkt
WebAssembly-Builds von AWS' Rust-Implementierung für generisches PartiQL, ohne
jede Vorstellung davon, was DynamoDB spezifisch ablehnt.
Also haben wir einen geschrieben: einen kleinen Lexer und einen Recursive-Descent-
Parser, modelliert auf die Form, die sql-parser-cst uns zu wollen beigebracht
hat. Jeder Node trägt seine Byte Range. Das Ganze hat null Runtime-Dependencies —
eine Eigenschaft, die die CI jetzt asserted, weil sie den Parser überall
einbettbar macht, im Browser eingeschlossen, in deinem Projekt eingeschlossen.
Die Grammatik war die einfache Hälfte. Der Parser eines Linters verbringt sein ganzes Leben damit, kaputten Code zu parsen. Mitten im Tastendruck, ein halbes Statement, ein Typo in der dritten Clause. Beim ersten Error zu stoppen würde den Editor nutzlos machen, also ist der Parser fehlertolerant: er notiert ein Diagnostic, resynchronisiert und macht weiter, sodass die vierte Clause noch gelintet wird, während die zweite unvollständig ist.
Die Engine tauschen, ohne das Flugzeug zu crashen
Als der Parser fertig war, trugen die vier Funktionen des Regex-Linters quer durch den Editor Last — die eingeschlossen, die entscheidet, ob ein Statement sicher automatisch ausgeführt werden kann. Dieses Verhalten still zu ändern zeigt sich als „der Editor führt meine Query nicht aus“, und das ist die Sorte Bug, die User weniger reporten als vielmehr zum Anlass nehmen zu gehen.
Also war der Swap ein Strangler: der alte Linter wurde umbenannt, eingefroren und im Tree behalten. Der neue parsergetriebene Linter re-exportierte exakt dieselben vier Funktionen. Und ein Parity-Korpus schickte jedes Fixture durch beide Linter und pinnte die Outputs gegeneinander — jedes Diagnostic, das die Regex- Version produzierte, musste die Parser-Version auch produzieren, bevor sie mehr produzieren durfte. Der alte Linter ist bis heute da, eingefroren, als ausführbare Dokumentation dessen, was der Swap versprochen hat.
Die Bugs, die nur Müll findet
Zwei Failures tauchten in keiner echten Query je auf, und beide hätten den Editor lahmgelegt.
Ein CodeMirror-Linter läuft synchron auf dem Dokument, bei jeder Änderung, ohne
Error Sink über sich. Eine uncaught Exception failt keinen Lint — sie
white-screent den Editor. Und ein Recursive-Descent-Parser hat eine natürliche
uncaught Exception eingebaut: den Call Stack. Paste [[[[[[… ein paar tausend
Brackets tief, oder eine NOT NOT NOT …-Kette, und jede Nesting-Ebene ist ein
Stack Frame; V8 wirft irgendwann RangeError: Maximum call stack size exceeded
geradewegs durch den Linter.
Die Fixes sind mit Absicht langweilig. Expression-Rekursion hat eine harte
Tiefengrenze — fünfhundert Ebenen, weit jenseits von allem, was ein Mensch
schreibt, deutlich unter dem Stack-Budget — jenseits derer der Parser ein einziges
Diagnostic emittiert, statt zu werfen. Und die Konstrukte, in denen Pastes
realistisch verketten, etwa A UNION B UNION C … tausende Arme lang, wurden von
Rekursion in flache Listen umgeschrieben: ein parseSelect-Frame und ein Array
von Set-Operations, statt ein Frame pro Arm. Die Stress Suite pastet jetzt bei
jedem Build 100 KB Müll und 30.000 Ebenen tiefe Operator-Ketten, und das
öffentliche Package wrappt die ganze Pipeline in einen lint()-Entry-Point, der
nie wirft, weil der nächste Editor, der das einbettet, dasselbe
No-Error-Sink-Problem haben wird wie wir.
Eine Test Suite, die du gegen AWS' Docs auditen kannst
Die Dialekt-Regeln — was DynamoDB akzeptiert, was es ablehnt, welches Rewrite was fixt — stammen alle aus AWS' PartiQL-Referenz. Dokumentationsabgeleitetes Verhalten hat einen spezifischen Failure Mode: die Doku bewegt sich, der Code nicht, und niemand merkt es.
Also ist das Korpus dagegen strukturiert. Zweihundertacht Fixtures, und jedes einzelne öffnet mit der URL der AWS-Dokumentationsseite, aus der die Regel kommt. Eine Coverage-Tabelle mappt jede dokumentierte Regel auf ihr Fixture, und die Suite failt, wenn eine Regel ihr Fixture verliert. Wenn AWS den Dialekt ändert, ist der Diff ein Fixture-Diff mit einer Quellenangabe darauf.
Diese Disziplin hat sich in der Woche des Open-Sourcing selbst bezahlt gemacht.
Die IN-Listen-Warnung des Linters zitierte zwei Caps: 50 Werte auf einer
Partition-Key-Spalte, 100 auf einer Non-Key-Spalte. Beim Re-Verifizieren jeder
Zahl vor der Veröffentlichung konnten wir die 100 in AWS' aktueller Dokumentation
bestätigen — und die 50 nirgends in einem gültigen Dokument finden. Sie überlebt
überall in Blog Posts und alten Forum-Antworten, aber die Primärquelle ist
weitergezogen. Der Linter hatte es zufällig richtig (er warnt erst jenseits von
100, da er ohne dein Schema nicht sagen kann, welcher Fall zutrifft), und der
Kommentar sagt jetzt genau, welche Hälfte der Behauptung dokumentiert ist und
welche Folklore.
Was transferiert, wenn du einen baust
- Ein handgeschriebener Recursive-Descent-Parser für einen kleinen Dialekt ist Tage Arbeit, keine Monate, und dir gehört jede Error Message. Die furchteinflößende Version von „schreib einen Parser“ unterstellt eine große Grammatik.
- Bau einen CST, keinen AST. Byte Ranges auf jedem Node sind das, was Diagnostics zu Quick Fixes macht; ein verlustbehafteter Tree kann keinen Text spleißen.
- Wenn der Parser einen Linter füttert, ist Fehlertoleranz das Feature. Recovere und mach weiter; ein Parser, der beim ersten Error stoppt, lintet danach nichts mehr.
- Tausch Engines hinter einem eingefrorenen Interface, mit einem Parity-Korpus, das Alt gegen Neu pinnt. Die alte Implementierung ist die Spec, der du schon zugestimmt hast.
- Überall, wo Input nesten kann, wird jemand etwas absurd tief Genestetes hineinpasten. Deckle die Rekursionstiefe und flache die Ketten ab; teste mit Müll, nicht nur mit Queries.
- Zitiere deine Quellen in den Tests. Ein Fixture, das die Doku-Seite benennt, die es kodiert, ist ein Test, der auditiert werden kann, wenn die Doku sich ändert — und sie wird sich ändern.
Der Parser ist auf GitHub
und npm (npm install dynamodb-partiql-parser), mit der Editor-Integration in
codemirror-lang-partiql.
Willst du den Dialekt selbst statt des Parsers, deckt
PartiQL vs SQL ab, was DynamoDBs Subset kann und
was nicht, und PartiQL-Beispiele ist der
praktische Walkthrough; der Editor, für den das alles gebaut wurde, ist
in DynoTable, und du kannst ihn
kostenlos ausprobieren.