· 8 min de lecture

Comment nous avons construit un moteur SQL local pour DynamoDB (une base de données sans JOIN)

DynamoDB peut exporter une table de 100 Go mais ne peut pas en agréger une. Il n’y a pas de JOIN, pas de GROUP BY, pas de COUNT(*) — pas de versions lentes de tout ça, aucune — et même , la surface SQL-esque d’AWS elle-même, ne les ajoute pas. Alors chaque équipe finit par écrire le même script jetable : paginer un , replier les lignes dans une map, afficher la réponse, supprimer le script.

Le Workbench de DynoTable est notre réponse à ce script : un onglet où tu écris un vrai SELECTJOIN … ON multi-tables, WHERE, GROUP BY, HAVING, DISTINCT, CASE, des agrégats — contre des tables qui n’en supportent aucun. Cet article, c’est comment ça marche sous le capot, le parser qui nous a silencieusement menti, et pourquoi nous avons plus tard arraché le modèle mémoire du moteur.

Ne construis pas de moteur de requêtes

La décision centrale a été de refuser d’écrire un exécuteur relationnel. Les pages DynamoDB streament dans une base de données SQLite embarquée, et SQLite — l’un des moteurs de requêtes les plus testés au monde — fait le travail relationnel :

which tables,which join keysrewritten SQLYour SQLCompilersql-parser-cstDynamoDBpage streamLocal SQLiteon diskResult grid

Chaque item DynamoDB atterrit dans SQLite sous forme de son enveloppe typée brute ({platform: {S: "ios"}}) dans une seule colonne item. Une petite fonction définie par l’utilisateur déballe les attributs au moment de la requête, si bien que ton SELECT platform compile vers un appel attr(item, 'platform', …). Un détail de cette fonction est porteur : elle enveloppe son parsing JSON dans un catch, parce que le binding SQLite transforme l’erreur de parsing d’une seule ligne malformée en un abandon de l’instruction préparée tout entière. Sans le catch, une seule ligne corrompue tuerait la requête.

La contrainte honnête que nous avons gardée : la physique des schémas d’accès de DynamoDB s’applique toujours. Le côté « vers » d’un JOIN doit être une clé primaire ou une clé de partition de — le moteur résout les jointures par recherche de clé contre le stream, jamais par scan en produit cartésien. Si tu as besoin de jointures arbitraires sur des attributs non-clés, c’est une conversation de modélisation, pas une fonctionnalité de moteur de requêtes.

Le Workbench : un JOIN SQL multi-tables, avec la grille de résultats jointe en dessous.
Le Workbench : un JOIN SQL multi-tables, avec la grille de résultats jointe en dessous.

Le parser qui nous a menti

La première version utilisait un parser SQL sur étagère populaire. Sur quelques semaines, quatre bugs de correction indépendants remontaient jusqu’à lui :

  • SELECT DISTINCT était silencieusement ignoré. Le parser l’acceptait et l’émetteur l’ignorait — chaque ligne dupliquée revenait. Le rapport de bug d’un utilisateur disait : « affiche une table de toutes les valeurs, pas des compteurs ».
  • Tout ce qui n’était pas géré devenait le NULL SQL littéral. CASE, CAST, les sous-requêtes scalaires — le repli de l’émetteur pour un nœud de syntaxe inconnu était return 'NULL'. Les requêtes s’exécutaient, renvoyaient des réponses fausses avec assurance, et ne levaient aucune erreur.
  • La casse des identifiants ne se comportait pas comme en SQL. SELECT PLATFORM FROM t renvoyait des nuls quand l’attribut était platform — l’inverse de ce à quoi toute base de données SQL t’entraîne à t’attendre.
  • L’autocomplétion était en désaccord avec le résolveur, suggérant des noms que le compilateur échouait ensuite à résoudre.

Le remplaçant, sql-parser-cst, a gagné sur une primitive que l’étude n’a trouvée nulle part ailleurs : son arbre préserve à la fois le texte brut et le nom normalisé de chaque identifiant — si bien que le compilateur peut distinguer platform de "PLATFORM". Cette seule distinction permet à une grammaire de porter une sémantique ANSI correcte : les identifiants non cités se résolvent sans tenir compte de la casse, ceux entre guillemets sont l’échappatoire sensible à la casse, exactement comme Postgres ou SQLite. Il porte aussi une plage source sur chaque nœud, si bien qu’un diagnostic souligne la construction fautive au lieu de l’instruction entière.

Et nous avons remplacé le repli return 'NULL' par une règle que nous traitons désormais comme une politique : pas de NULL silencieux, jamais. Le bras par défaut de l’émetteur est une erreur à plage précise. Les fonctions de fenêtrage obtiennent un message qui dit pourquoi, pas seulement non :

Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.

Le compromis que nous avons accepté : une dépendance pré-1.0, épinglée à la version exacte, mise à jour uniquement à la main avec la suite de tests comme garde-fou. Nous avons évalué le patch de l’ancien parser (il fallait un pré-tokeniseur parallèle — parser chaque entrée deux fois), l’usage de l’arbre syntaxique de l’éditeur (au niveau des tokens, sans structure de clauses), et le fait maison (l’essentiel d’un parser SQLite, maintenance sans limite). Un échec de correction sur quatre fronts bat toutes ces préoccupations.

Savoir ce qu’il ne faut pas bloquer

Un diagnostic ne t’arrête délibérément pas. Quand un filtre référence un attribut dont le schéma ne peut pas confirmer la casse, nous ne pouvons pas connaître la casse canonique — les filtres côté serveur de DynamoDB sont sensibles à la casse et son schéma ne déclare que les attributs de clé. Cela émet un avertissement, et les avertissements ne désactivent jamais Run. Nous avons appris cette classe de leçon à la dure dans notre travail sur les validateurs : un garde qui rejette la forme de requête légitime la plus courante est pire que pas de garde du tout.

Le plafond avait la mauvaise forme

Le premier moteur d’agrégation tamponnait les lignes scannées dans un SQLite en mémoire avec un plafond de taille — 64 Mo ici, 250 Mo là, et un chemin sans plafond qui pouvait faire manquer de mémoire à l’app sur une grande table. Atteins le plafond et tu obtenais une réponse partielle avec un drapeau de débordement.

La refonte a commencé par un recadrage : le concurrent est le script jetable qu’un développeur écrirait à la place. Et un script compétent ne tamponne-puis-plafonne pas — il streame, repliant chaque page dans un total courant avec une mémoire bornée. Le plafond en mémoire n’était pas trop petit ; il avait la mauvaise forme. Pire, un agrégat partiel n’est pas « incomplet » — il est faux. Le moment qui a rendu ça concret : notre propre démo IA a raconté avec assurance un classement partiel des « plus gros dépensiers » comme un fait.

Le moteur Compute reconstruit fonctionne comme le script, plus tout ce que le script ne s’embête jamais à faire :

  • Un planificateur classe chaque requête dans l’une de trois voies : stream-fold (agrégats algébriques simples — COUNT/SUM/AVG/MIN/MAX — repliés page par page hors du processus principal ; la taille ne conditionne jamais une requête repliable, elle prend juste plus de temps), materialize (tris, jointures bornées par clé — déversés vers un SQLite adossé au disque, sans plafond mémoire), ou un refus honnête qui recommande le bon outil lourd (l’export S3 de DynamoDB plus Athena) pour la traîne que nous ne pouvons pas servir.
  • La classification de fold est délibérément étroite. Un HAVING, un ORDER BY, une colonne nue à côté de l’agrégat — n’importe lequel force la voie materialize. Un fold qui ignorerait une clause et rapporterait quand même « exact » serait précisément le bug faux-mais-confiant que ce planificateur existe pour prévenir.
  • L’exactitude fait partie du résultat. Les colonnes d’agrégat affichent un badge partiel tant que les pages streament encore, et ne le lâchent que lorsque le Scan est vidé. Même l’honnêteté arithmétique est suivie : les sommes d’entiers restent exactes au-delà de 2⁵³ (stockées en entiers 64 bits), tandis qu’une somme fractionnaire qui dépasse la mantisse flottante se signale elle-même partial: precision plutôt que d’arrondir en silence.
  • Les fusibles remplacent les popups pour les machines. L’app interactive demande avant un Scan de cinq millions d’items ; l’assistant IA et les appelants MCP obtiennent des budgets de Scan et des abandons à la frontière de page à la place. Et l’enveloppe de résultat que le modèle voit est délibérément rognée — la classe de coût et les estimations de taille sont retenues, parce que présenter une supposition périmée invite le modèle à la raconter comme un fait.

Une note de coût qui a surpris les gens en interne : pour un agrégat ponctuel, un Scan en direct est environ 6× moins cher que de générer un export DynamoDB→S3 — c’est pourquoi la voie en forme de script est le défaut et l’entrepôt est la recommandation pour de l’analytique lourde répétée, pas le réflexe. (Le calculateur de tarifs chiffrera une passe complète de ta propre table.)

Est-il sûr de pointer du SQL vers la production ?

Le Workbench est structurellement en lecture seule. Le SQL final qui atteint le moteur local passe par un garde en défense en profondeur qui tokenise et rejette tout ce qui n’est pas un unique SELECT — en tant que violation de contrat, pas d’erreur utilisateur, parce que le compilateur n’aurait jamais dû le produire. Le substrat SQLite tourne avec les built-ins dangereux désactivés, et les écritures vers DynamoDB ont exactement un chemin dans toute l’app : la zone de staging vérifiable, que le SQL ne peut pas atteindre.

Ce qui se transfère si tu en construis un

  • N’écris pas de moteur de requêtes ; écris un compilateur fidèle vers un moteur qui existe déjà. Tes bugs vivront dans les jointures (casse des identifiants, enveloppes de types, lignes corrompues), pas dans l’algorithme de jointure.
  • Exige la provenance des guillemets de ton parser. S’il ne peut pas distinguer name de "NAME", tu ne peux pas implémenter la sémantique de casse de SQL, et tes utilisateurs le découvriront avant toi.
  • Fais de la « syntaxe non gérée » une erreur bruyante, jamais une valeur par défaut. Le NULL silencieux, c’est ainsi que les réponses fausses et confiantes sont livrées.
  • Benchmarke ta conception contre le script jetable que ton utilisateur écrirait. Si le script streame et que tu tamponnes, tu as construit la mauvaise forme.
  • Traite les agrégats partiels comme des réponses fausses qui ont besoin d’une étiquette, pas comme une note partielle.

La doc du Workbench couvre la surface fonctionnelle au quotidien, et SQL pour DynamoDB cartographie ce qui marche à travers tout l’écosystème. Ou simplement télécharge DynoTable, ouvre un onglet Workbench avec ⌘⌥Q, et exécute un JOIN contre la base de données qui n’en a pas.

Travaille avec DynamoDB sans la Console

DynoTable est un client de bureau rapide pour DynamoDB — parcours les tables, exécute des requêtes de style SQL et édite les items en local.