· 7 min di lettura

Perché abbiamo scritto a mano un parser PartiQL per DynamoDB

DynamoDB accetta una porzione ristretta di e rifiuta tutto il resto al momento della richiesta. GROUP BY? ValidationException. Un LIMIT a livello di statement? ValidationException. L'operatore *, CAST, una subquery? Nella tua testa vengono analizzati tutti benissimo, viaggiano sulla rete e muoiono sul server. L'unico posto in cui quella conoscenza era registrata erano la documentazione AWS e i messaggi di errore, il che significava che ogni editor per DynamoDB — incluso il nostro, per un certo periodo — ti lasciava tranquillamente comporre uno statement che il motore avrebbe sicuramente rifiutato.

Volevamo che il rifiuto avvenisse nell'editor, alla pressione del tasto, con una sottolineatura rossa sulla clausola esatta e una correzione a un clic dove esiste una riscrittura. Quell'esigenza dell'editor si è trasformata in un lexer e in un parser CST scritti a mano per il dialetto PartiQL di DynamoDB, e questa settimana lo abbiamo rilasciato open source: dynamodb-partiql-parser, TypeScript puro, zero dipendenze, MIT, con l'integrazione CodeMirror pubblicata separatamente come codemirror-lang-partiql. Questo post spiega perché è scritto a mano, cosa sbagliava il primo linter e i due bug che sono emersi solo quando qualcuno ha incollato della spazzatura.

Le regex andavano bene, finché non è stato più così

Il primo linter PartiQL di DynoTable era fatto di circa 650 righe tra regex e scansione di token, ed era davvero utile: diciannove controlli distinti, quick fix per le trappole più comuni (IN (...) in [...], LIKE in contains(), IS NULL in attribute_not_exists()). È stato rilasciato, ha intercettato errori reali, gli utenti hanno smesso di aprire ticket «perché la mia query fallisce» per i casi che copriva.

Ma un linter a regex conosce i pattern, non la struttura. Non poteva accorgersi che * in SELECT price * quantity è un'operazione aritmetica che DynamoDB rifiuta, perché * significa anche «tutte le colonne» e distinguere i due casi richiede di fare parsing sul serio. I suoi intervalli diagnostici erano approssimazioni: abbastanza precisi da indicare una riga, troppo grossolani per guidare una quick fix che innesta testo a offset esatti. E ogni nuovo controllo rendeva la pila più fragile, perché ogni regex doveva difendersi dalle assunzioni di tutte le altre.

La soluzione a «il linter ha bisogno di struttura» è un parser. La domanda era quale.

Nessuno ne aveva costruito uno

Per il lato SQL vero del Workbench eravamo già passati di qui: un parser SQL preconfezionato che ci mentiva, sostituito da sql-parser-cst, che porta un intervallo di origine su ogni nodo e preserva la distinzione fra identificatori tra virgolette e senza. Quell'esperienza ha fissato l'asticella per ciò che serviva al lato PartiQL: un albero sintattico concreto senza perdite, non un AST che scarta informazioni.

Ma PartiQL non è SQL proprio dove conta per un parser. Il dialetto di DynamoDB scrive le liste IN con le parentesi quadre (WHERE OrderID IN [100, 300, 234]), ha i letterali bag (<<'a', 'b'>>), i letterali mappa con chiavi tra virgolette ({'rating': 5}), un letterale MISSING, percorsi di documento con indici di lista (Devices.FireStick.DateWatched[0]) e RETURNING ALL OLD * — nulla di tutto questo è noto a una grammatica SQL. Nella direzione opposta gli manca metà di ciò che una grammatica SQL pretende. All'epoca i parser su npm erano build WebAssembly dell'implementazione Rust di AWS per il PartiQL generico, senza alcuna nozione di ciò che DynamoDB rifiuta nello specifico.

Così ne abbiamo scritto uno: un piccolo lexer e un parser a discesa ricorsiva, modellato sulla forma che sql-parser-cst ci ha insegnato a volere. Ogni nodo porta con sé il proprio intervallo di byte. L'insieme non ha alcuna dipendenza a runtime — una proprietà che ora la CI verifica, perché è ciò che rende il parser incorporabile ovunque, browser incluso, il tuo progetto incluso.

La grammatica era la metà facile. Il parser di un linter passa tutta la vita ad analizzare codice rotto. A metà digitazione, mezzo statement, un refuso nella terza clausola. Fermarsi al primo errore renderebbe l'editor inutile, quindi il parser è tollerante agli errori: registra una diagnostica, si risincronizza e prosegue, così la quarta clausola viene comunque analizzata mentre la seconda è incompleta.

Sostituire il motore senza far cadere l'aereo

Quando il parser è stato pronto, le quattro funzioni del linter a regex erano ormai portanti in tutto l'editor — inclusa quella che decide se uno statement è sicuro da eseguire automaticamente. Cambiare quel comportamento in silenzio si manifesta come «l'editor non esegue la mia query», il tipo di bug che gli utenti non tanto segnalano quanto usano come motivo per andarsene.

Così la sostituzione è stata uno strangler: il vecchio linter è stato rinominato, congelato e tenuto nel repository. Il nuovo linter guidato dal parser ri-esportava esattamente le stesse quattro funzioni. E un corpus di parità faceva passare ogni fixture attraverso entrambi i linter, fissando gli output l'uno contro l'altro: ogni diagnostica prodotta dalla versione a regex doveva essere prodotta anche dalla versione con parser, prima che le fosse concesso di produrne altre. Il vecchio linter è ancora lì oggi, congelato, come documentazione eseguibile di ciò che la sostituzione aveva promesso.

I bug che trova solo la spazzatura

Due malfunzionamenti non sono mai comparsi in una query reale ed entrambi avrebbero messo KO l'editor.

Un linter CodeMirror gira in modo sincrono sul documento, a ogni modifica, senza alcun error sink sopra di sé. Un'eccezione non catturata non fa fallire un lint: manda l'editor in schermata bianca. E un parser a discesa ricorsiva ha un'eccezione non catturata incorporata per natura: lo stack delle chiamate. Incolla [[[[[[… per qualche migliaio di parentesi, o una catena NOT NOT NOT …, e ogni livello di annidamento è un frame di stack; a un certo punto V8 lancia RangeError: Maximum call stack size exceeded dritto attraverso il linter.

Le correzioni sono noiose di proposito. La ricorsione delle espressioni ha un tetto rigido di profondità — cinquecento livelli, ben oltre qualsiasi cosa scriva un essere umano, ben sotto il budget di stack — superato il quale il parser emette una singola diagnostica invece di lanciare un'eccezione. E i costrutti in cui gli incollaggi si concatenano realisticamente, come A UNION B UNION C … lungo migliaia di rami, sono stati riscritti da ricorsione a liste piatte: un solo frame parseSelect e un array di operazioni insiemistiche, invece di un frame per ramo. La suite di stress ora incolla 100 KB di spazzatura e catene di operatori profonde 30.000 a ogni build, e il pacchetto pubblico avvolge l'intera pipeline in un entry point lint() che non lancia mai eccezioni, perché il prossimo editor che lo incorporerà avrà lo stesso problema di error sink assente che avevamo noi.

Una suite di test verificabile contro la documentazione AWS

Le regole del dialetto — cosa DynamoDB accetta, cosa rifiuta, quale riscrittura risolve cosa — vengono tutte dalla reference PartiQL di AWS. Il comportamento derivato dalla documentazione ha una modalità di guasto specifica: la documentazione cambia, il codice no, e nessuno se ne accorge.

Perciò il corpus è strutturato di conseguenza. Duecentotto fixture, e ognuna si apre con l'URL della pagina di documentazione AWS da cui proviene la regola. Una tabella di copertura mappa ogni regola documentata sulla sua fixture, e la suite fallisce se una regola perde la propria fixture. Quando AWS cambia il dialetto, la diff è una diff di fixture con sopra una citazione.

Quella disciplina si è ripagata la settimana in cui abbiamo aperto il codice. L'avviso del linter sulle liste IN citava due limiti: 50 valori su una colonna che è chiave di partizione, 100 su una colonna non chiave. Riverificando ogni numero prima della pubblicazione, abbiamo potuto confermare il 100 nella documentazione attuale di AWS — e non siamo riusciti a trovare il 50 da nessuna parte in un documento operativo. Sopravvive ovunque tra post di blog e vecchie risposte sui forum, ma la fonte primaria è andata avanti. Il linter aveva ragione per caso (avvisa solo oltre 100, dato che senza il tuo schema non può sapere quale dei due casi si applichi), e il commento ora dice esattamente quale metà dell'affermazione è documentata e quale è folklore.

Cosa si trasferisce se ne stai costruendo uno

  • Un parser a discesa ricorsiva scritto a mano per un dialetto piccolo è lavoro di giorni, non di mesi, e possiedi ogni messaggio di errore. La versione spaventosa di «scrivi un parser» presuppone una grammatica grande.
  • Costruisci un CST, non un AST. Gli intervalli di byte su ogni nodo sono ciò che trasforma le diagnostiche in quick fix; un albero con perdite non può innestare testo.
  • Se il parser alimenta un linter, la tolleranza agli errori è la funzionalità. Recupera e prosegui; un parser che si ferma al primo errore non analizza più nulla dopo di esso.
  • Sostituisci i motori dietro un'interfaccia congelata, con un corpus di parità che fissa il vecchio contro il nuovo. La vecchia implementazione è la specifica che hai già accettato.
  • Ovunque l'input possa annidarsi, qualcuno incollerà qualcosa che si annida in modo assurdo. Metti un tetto alla profondità della ricorsione e appiattisci le catene; testa con la spazzatura, non solo con le query.
  • Cita le fonti nei test. Una fixture che nomina la pagina di documentazione che codifica è un test verificabile quando quella pagina cambia — e cambierà.

Il parser è su GitHub e su npm (npm install dynamodb-partiql-parser), con l'integrazione per l'editor in codemirror-lang-partiql. Se ti interessa il dialetto in sé più che il parser, PartiQL vs SQL copre cosa il sottoinsieme di DynamoDB può e non può fare e gli esempi PartiQL sono la guida pratica; l'editor per cui tutto questo è stato costruito è in DynoTable, e puoi provarlo gratis.

Lavora con DynamoDB senza la Console

Un client desktop veloce per DynamoDB che esegue il vero SQL che DynamoDB non può — JOINs, GROUP BY, aggregazioni — con modifica visuale e un agente AI sulle tue chiavi Bedrock.

Prova gratuita di 30 giorni, senza carta di credito — poi il piano Free senza limiti di tempo.