Wie wir eine lokale SQL-Engine für DynamoDB gebaut haben (eine Datenbank ohne JOINs)
DynamoDB kann eine 100-GB-Tabelle exportieren, aber es kann keine
aggregieren. Es gibt kein JOIN, kein GROUP BY, kein COUNT(*) — keine
langsamen Versionen davon, gar keine — und selbst ,
AWS' eigene SQL-artige Oberfläche, fügt sie nicht
hinzu. Also schreibt am Ende jedes Team dasselbe
Wegwerf-Skript: einen paginieren, Zeilen in eine Map falten,
die Antwort ausgeben, das Skript löschen.
DynoTables Workbench ist unsere Antwort auf dieses
Skript: ein Tab, in dem du ein einziges echtes SELECT schreibst —
tabellenübergreifendes JOIN … ON, WHERE, GROUP BY, HAVING,
DISTINCT, CASE, Aggregate — gegen Tabellen, die nichts davon
unterstützen. Dieser Beitrag ist, wie es unter der Haube funktioniert, der
Parser, der uns stillschweigend belog, und warum wir später das
Speichermodell der Engine herausgerissen haben.
Baue keine Query-Engine
Die Kernentscheidung war, es abzulehnen, einen relationalen Executor zu schreiben. DynamoDB-Seiten streamen in eine eingebettete SQLite-Datenbank, und SQLite — eine der am meisten getesteten Query-Engines der Welt — erledigt die relationale Arbeit:
Jedes DynamoDB-Item landet in SQLite als sein rohes, typisiertes Envelope
({platform: {S: "ios"}}) in einer einzigen item-Spalte. Eine kleine
benutzerdefinierte Funktion packt Attribute zur Query-Zeit aus, sodass dein
SELECT platform zu einem attr(item, 'platform', …)-Aufruf kompiliert.
Ein Detail in dieser Funktion ist tragend: Sie umschließt ihr JSON-Parsing
mit einem Catch, weil die SQLite-Bindung den Parse-Fehler einer einzigen
fehlerhaften Zeile in einen Abbruch des gesamten Prepared Statements
verwandelt. Ohne den Catch würde eine einzige korrupte Zeile die Query
killen.
Die ehrliche Einschränkung, die wir beibehalten haben: Die
Zugriffsmuster-Physik von DynamoDB gilt weiterhin. Die To-Seite eines
JOIN muss ein Primärschlüssel oder ein -Partition-Key sein —
die Engine löst Joins per Key-Lookup gegen den Stream auf, nie per
Kreuzprodukt-Scan. Wenn du beliebige Joins auf Nicht-Schlüssel-Attributen
brauchst, ist das ein Modellierungsgespräch, kein
Feature der Query-Engine.

Der Parser, der uns belog
Die erste Version nutzte einen beliebten SQL-Parser von der Stange. Über ein paar Wochen ließen sich vier unabhängige Korrektheits-Bugs auf ihn zurückführen:
SELECT DISTINCTwurde stillschweigend fallengelassen. Der Parser akzeptierte es und der Emitter ignorierte es — jede doppelte Zeile kam zurück. Der Bug-Report eines Nutzers lautete: „zeigt eine Tabelle aller Werte, keine Zähler.“- Alles Unbehandelte wurde zum literalen SQL-
NULL.CASE,CAST, skalare Subqueries — der Fallback des Emitters für einen unbekannten Syntaxknoten warreturn 'NULL'. Queries liefen, gaben selbstbewusst falsche Antworten zurück und lösten keinen Fehler aus. - Die Groß-/Kleinschreibung von Bezeichnern verhielt sich nicht wie
SQL.
SELECT PLATFORM FROM tgab Nulls zurück, wenn das Attributplatformhieß — das Gegenteil von dem, worauf dich jede SQL-Datenbank trainiert. - Die Autovervollständigung widersprach dem Resolver und schlug Namen vor, die der Compiler dann nicht auflösen konnte.
Der Ersatz, sql-parser-cst, gewann bei einem Primitiv, das die Marktschau
nirgendwo sonst fand: Sein Baum bewahrt sowohl den Rohtext als auch den
normalisierten Namen jedes Bezeichners — sodass der Compiler platform
von "PLATFORM" unterscheiden kann. Diese eine Unterscheidung lässt eine
einzige Grammatik saubere ANSI-Semantik tragen: Nicht in Anführungszeichen
gesetzte Bezeichner werden case-insensitiv aufgelöst, in Anführungszeichen
gesetzte sind die case-sensitive Notausstiegsluke, genau wie bei Postgres
oder SQLite. Er trägt außerdem eine Source-Range an jedem Knoten, sodass
eine Diagnose das beanstandete Konstrukt unterstreicht statt des ganzen
Statements.
Und wir ersetzten den return 'NULL'-Fallback durch eine Regel, die wir
jetzt als Richtlinie behandeln: niemals ein stilles NULL. Der
Default-Zweig des Emitters ist ein Fehler mit präziser Range.
Window-Funktionen erhalten eine Nachricht, die sagt, warum, nicht nur nein:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.Der Kompromiss, den wir eingegangen sind: eine Pre-1.0-Abhängigkeit, auf die exakte Version gepinnt, nur von Hand aktualisiert mit der Test-Suite als Gate. Wir haben in Betracht gezogen, den alten Parser zu patchen (er brauchte einen parallelen Pre-Tokenizer — jede Eingabe zweimal parsen), den Syntaxbaum des Editors zu nutzen (Token-Ebene, keine Klausel-Struktur) und selbst zu bauen (ein Großteil eines SQLite-Parsers, unbegrenzte Wartung). Ein vierfaches Korrektheitsversagen wiegt schwerer als all diese Bedenken.
Wissen, was nicht zu blockieren ist
Eine Diagnose stoppt dich bewusst nicht. Wenn ein Filter ein Attribut referenziert, dessen Schreibweise das Schema nicht bestätigen kann, können wir den kanonischen Fall nicht kennen — die serverseitigen Filter von DynamoDB sind case-sensitiv und sein Schema deklariert nur Schlüsselattribute. Das gibt eine Warnung aus, und Warnungen deaktivieren Run nie. Diese Art von Lektion haben wir bei unserer Validator-Arbeit auf die harte Tour gelernt: Ein Gate, das die häufigste legitime Query-Form ablehnt, ist schlimmer als gar kein Gate.
Das Limit hatte die falsche Form
Die erste Aggregations-Engine pufferte gescannte Zeilen in ein In-Memory-SQLite mit einer Größenobergrenze — 64 MB hier, 250 MB dort, und ein Pfad ohne Obergrenze, der die App bei einer großen Tabelle per OOM abschießen konnte. Erreichte man die Obergrenze, bekam man eine partielle Antwort mit einem Overflow-Flag.
Das Redesign begann mit einer Neurahmung: Der Konkurrent ist das Wegwerf-Skript, das ein Entwickler stattdessen schreiben würde. Und ein kompetentes Skript puffert-dann-begrenzt nicht — es streamt und faltet jede Seite in eine laufende Summe mit beschränktem Speicher. Die In-Memory-Obergrenze war nicht zu klein; sie hatte die falsche Form. Schlimmer noch: Ein partielles Aggregat ist nicht „unvollständig“ — es ist falsch. Der Moment, der das konkret machte: Unsere eigene KI-Demo erzählte selbstbewusst ein partielles „Top-Spender“-Ranking als Fakt.
Die neu gebaute Compute-Engine arbeitet so, wie es das Skript tut, plus alles, worum sich das Skript nie kümmert:
- Ein Planer klassifiziert jede Query in eine von drei Bahnen:
Stream-Fold (einfache algebraische Aggregate —
COUNT/SUM/AVG/MIN/MAX— Seite für Seite außerhalb des Hauptprozesses gefaltet; die Größe blockiert eine faltbare Query nie, sie dauert nur länger), Materialize (Sortierungen, schlüsselbegrenzte Joins — auf plattengestütztes SQLite ausgelagert, keine Speicherobergrenze) oder eine ehrliche Ablehnung, die das richtige schwergewichtige Tool (DynamoDBs S3-Export plus Athena) für den Rest empfiehlt, den wir nicht bedienen können. - Die Fold-Klassifikation ist bewusst eng. Ein
HAVING, einORDER BY, eine nackte Spalte neben dem Aggregat — jedes davon erzwingt die Materialize-Bahn. Ein Fold, der eine Klausel ignorierte und trotzdem „exakt“ meldete, wäre genau der falsch-aber-selbstbewusste Bug, zu dessen Verhinderung dieser Planer existiert. - Die Exaktheit ist Teil des Ergebnisses. Aggregat-Spalten zeigen ein
Partial-Badge, solange noch Seiten streamen, und lassen es erst fallen,
wenn der Scan leergelaufen ist. Selbst die arithmetische Ehrlichkeit wird
verfolgt: Ganzzahlsummen bleiben über 2⁵³ hinaus exakt (als 64-Bit-Integer
gespeichert), während eine gebrochene Summe, die die Float-Mantisse
übersteigt, sich selbst als
partial: precisionmarkiert, statt still zu runden. - Sicherungen ersetzen Popups für Maschinen. Die interaktive App fragt vor einem Scan über fünf Millionen Items nach; der KI-Assistent und MCP-Aufrufer bekommen stattdessen Scan-Budgets und Abbrüche an Seitengrenzen. Und die Ergebnis-Hülle, die das Modell sieht, ist bewusst gestutzt — Kostenklasse und Größenschätzungen werden zurückgehalten, weil das Offenlegen einer veralteten Schätzung das Modell einlädt, sie als Fakt zu erzählen.
Eine Kostennotiz, die intern überraschte: Für ein einmaliges Aggregat ist ein Live-Scan grob 6× günstiger als das Generieren eines DynamoDB→S3-Exports — weshalb die skriptförmige Bahn der Standard ist und das Warehouse die Empfehlung für wiederholte schwere Analytik, nicht der Reflex. (Der Preis-Rechner berechnet den Preis für einen kompletten Durchlauf deiner eigenen Tabelle.)
Ist es sicher, SQL auf die Produktion zu richten?
Die Workbench ist strukturell schreibgeschützt. Das finale SQL, das die
lokale Engine erreicht, durchläuft ein Defense-in-Depth-Gate, das alles,
was kein einzelnes SELECT ist, tokenisiert und ablehnt — als
Vertragsverletzung, nicht als Nutzerfehler, weil der Compiler es niemals
hätte erzeugen dürfen. Das SQLite-Substrat läuft mit deaktivierten
gefährlichen Built-ins, und Schreibvorgänge nach DynamoDB haben in der
gesamten App genau einen Pfad: den überprüfbaren
Staging-Bereich, den SQL nicht erreichen kann.
Was übertragbar ist, wenn du selbst eine baust
- Schreibe keine Query-Engine; schreibe einen getreuen Compiler auf eine, die bereits existiert. Deine Bugs werden in den Nahtstellen leben (Bezeichner-Schreibweise, Typ-Envelopes, korrupte Zeilen), nicht im Join-Algorithmus.
- Verlange Quote-Provenienz von deinem Parser. Wenn er
namenicht von"NAME"unterscheiden kann, kannst du die Case-Semantik von SQL nicht implementieren, und deine Nutzer finden es vor dir heraus. - Mach „unbehandelte Syntax“ zu einem lauten Fehler, nie zu einem
Default-Wert. Stilles
NULList, wie selbstbewusst falsche Antworten ausgeliefert werden. - Benchmarke dein Design gegen das Wegwerf-Skript, das dein Nutzer schreiben würde. Wenn das Skript streamt und du pufferst, hast du die falsche Form gebaut.
- Behandle partielle Aggregate als falsche Antworten, die eine Kennzeichnung brauchen, nicht als Teilpunkte.
Die Workbench-Doku behandelt die alltägliche
Feature-Oberfläche, und SQL für DynamoDB
kartiert, was im gesamten Ökosystem funktioniert. Oder lade einfach
DynoTable herunter, öffne einen Workbench-Tab mit
⌘⌥Q und führe ein JOIN gegen die Datenbank aus, die keins hat.


