Cómo construimos un motor SQL local para DynamoDB (una base de datos sin JOIN)
DynamoDB puede exportar una tabla de 100 GB pero no puede agregar una. No hay
JOIN, ni GROUP BY, ni COUNT(*) —no versiones lentas de ellos, ninguno—
e incluso , la propia superficie tipo SQL de AWS, no los
añade. Así que cada equipo acaba escribiendo el mismo
script desechable: paginar un , plegar las filas en un mapa,
imprimir la respuesta, borrar el script.
El Workbench de DynoTable es nuestra respuesta a ese script:
una pestaña donde escribes un SELECT de verdad —JOIN … ON multitabla,
WHERE, GROUP BY, HAVING, DISTINCT, CASE, agregados— contra tablas que
no admiten nada de eso. Esta entrada trata de cómo funciona por dentro, del
parser que nos mintió en silencio y de por qué más tarde arrancamos el modelo
de memoria del motor.
No construyas un motor de consultas
La decisión central fue negarnos a escribir un ejecutor relacional. Las páginas de DynamoDB se transmiten a una base de datos SQLite embebida, y SQLite —uno de los motores de consultas más probados del planeta— hace el trabajo relacional:
Cada Item de DynamoDB aterriza en SQLite como su envoltorio tipado en crudo
({platform: {S: "ios"}}) en una única columna item. Una pequeña función
definida por el usuario desenvuelve los atributos en tiempo de consulta, así
que tu SELECT platform compila a una llamada attr(item, 'platform', …).
Un detalle de esa función es de carga: envuelve su análisis de JSON en un
catch, porque el binding de SQLite convierte el error de análisis de una fila
malformada en un aborto de la sentencia preparada entera. Sin el catch, una
fila corrupta mataría la consulta.
La restricción honesta que mantuvimos: la física de los patrones de acceso de
DynamoDB sigue aplicándose. El lado destino de un JOIN debe ser una clave
primaria o una clave de partición de un —el motor resuelve las
uniones por búsqueda de clave contra el stream, nunca por un escaneo de producto
cruzado. Si necesitas uniones arbitrarias sobre atributos que no son clave, eso
es una conversación de modelado, no una función del
motor de consultas.

El parser que nos mintió
La primera versión usaba un popular parser de SQL ya hecho. A lo largo de unas semanas, cuatro bugs de corrección independientes se rastrearon hasta él:
SELECT DISTINCTse descartaba en silencio. El parser lo aceptaba y el emisor lo ignoraba —cada fila duplicada volvía. El informe de bug de un usuario decía: «muestra una tabla de todos los valores, no contadores».- Cualquier cosa no gestionada se convertía en el
NULLliteral de SQL.CASE,CAST, subconsultas escalares —el recurso del emisor ante un nodo de sintaxis desconocido erareturn 'NULL'. Las consultas se ejecutaban, devolvían respuestas incorrectas con seguridad y no lanzaban ningún error. - El uso de mayúsculas en los identificadores no se comportaba como en SQL.
SELECT PLATFORM FROM tdevolvía nulos cuando el atributo eraplatform—lo contrario de lo que toda base de datos SQL te entrena a esperar. - El autocompletado discrepaba del resolvedor, sugiriendo nombres que el compilador luego no lograba resolver.
El reemplazo, sql-parser-cst, ganó por una primitiva que el sondeo no encontró
en ningún otro sitio: su árbol conserva tanto el texto en crudo como el
nombre normalizado de cada identificador —así que el compilador puede
distinguir platform de "PLATFORM". Esa única distinción permite que una
sola gramática lleve la semántica ANSI adecuada: los identificadores sin
comillas se resuelven sin distinguir mayúsculas, los entrecomillados son la vía
de escape sensible a mayúsculas, exactamente como Postgres o SQLite. También
lleva un rango de origen en cada nodo, así que un diagnóstico subraya la
construcción infractora en lugar de toda la sentencia.
Y reemplazamos el recurso return 'NULL' por una regla que ahora tratamos como
política: ningún NULL silencioso, jamás. El brazo por defecto del emisor es
un error con rango preciso. Las funciones de ventana reciben un mensaje que
dice por qué, no solo que no:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.El compromiso que aceptamos: una dependencia anterior a la 1.0, fijada a la versión exacta, actualizada solo a mano con la suite de pruebas como barrera. Evaluamos parchear el viejo parser (necesitaba un pre-tokenizador paralelo —analizar cada entrada dos veces), usar el árbol de sintaxis del editor (a nivel de token, sin estructura de cláusulas) y escribirlo a mano (la mayor parte de un parser de SQLite, mantenimiento ilimitado). Un fallo de corrección por cuádruple partida supera todas esas preocupaciones.
Saber qué no bloquear
Un diagnóstico deliberadamente no te detiene. Cuando un filtro referencia un atributo cuyo uso de mayúsculas el esquema no puede confirmar, no podemos conocer la forma canónica —los filtros del lado del servidor de DynamoDB distinguen mayúsculas y su esquema solo declara los atributos clave. Eso emite una advertencia, y las advertencias nunca desactivan Ejecutar. Aprendimos esta clase de lección por las malas en nuestro trabajo con validadores: una barrera que rechaza la forma de consulta legítima más común es peor que ninguna barrera.
El tope tenía la forma equivocada
El primer motor de agregación almacenaba en búfer las filas escaneadas en un SQLite en memoria con un tope de tamaño —64 MB aquí, 250 MB allá, y una ruta sin tope que podía provocar un OOM en la app con una tabla grande. Alcanzabas el tope y obtenías una respuesta parcial con una bandera de desbordamiento.
El rediseño empezó con un replanteamiento: el competidor es el script desechable que un desarrollador escribiría en su lugar. Y un script competente no almacena-en-búfer-y-luego-topa —transmite, plegando cada página en un total acumulado con memoria acotada. El tope en memoria no era demasiado pequeño; tenía la forma equivocada. Peor aún, un agregado parcial no está «incompleto»: está mal. El momento que hizo esto concreto: nuestra propia demo de IA narró con seguridad un ranking parcial de «mayores gastadores» como si fuera un hecho.
El motor Compute reconstruido funciona como lo hace el script, más todo aquello de lo que el script nunca se molesta:
- Un planificador clasifica cada consulta en uno de tres carriles:
stream-fold (agregados algebraicos simples —
COUNT/SUM/AVG/MIN/MAX— plegados página a página fuera del proceso principal; el tamaño nunca limita una consulta plegable, solo tarda más), materialize (ordenaciones, uniones acotadas por clave —volcadas a SQLite respaldado en disco, sin tope de memoria), o un rechazo honesto que recomienda la herramienta pesada adecuada (la exportación a S3 de DynamoDB más Athena) para la cola que no podemos servir. - La clasificación de plegado es deliberadamente estrecha. Un
HAVING, unORDER BY, una columna suelta junto al agregado —cualquiera de ellos fuerza el carril de materialización. Un plegado que ignorara una cláusula y aun así informara «exacto» sería precisamente el bug de estar-equivocado-pero-seguro que este planificador existe para prevenir. - La exactitud es parte del resultado. Las columnas de agregado muestran una
insignia partial mientras las páginas aún se transmiten, y la quitan solo
cuando el Scan se ha agotado. Incluso la honestidad aritmética se rastrea: las
sumas de enteros se mantienen exactas más allá de 2⁵³ (almacenadas como
enteros de 64 bits), mientras que una suma fraccionaria que excede la mantisa
del float se marca a sí misma como
partial: precisionen lugar de redondear en silencio. - Los fusibles reemplazan a los popups para las máquinas. La app interactiva pregunta antes de un Scan de cinco millones de elementos; el asistente de IA y los llamadores de MCP obtienen en su lugar presupuestos de escaneo y abortos en el límite de página. Y el sobre de resultado que ve el modelo está deliberadamente recortado —la clase de coste y las estimaciones de tamaño se ocultan, porque exponer una suposición obsoleta invita al modelo a narrarla como un hecho.
Una nota de coste que sorprendió a la gente internamente: para un agregado puntual, un Scan en vivo es aproximadamente 6× más barato que generar una exportación DynamoDB→S3 —razón por la cual el carril con forma de script es el predeterminado y el almacén de datos es la recomendación para analíticas pesadas repetidas, no el reflejo. (La calculadora de precios calculará el precio de una pasada completa de tu propia tabla.)
¿Es seguro apuntar SQL a producción?
El Workbench es estructuralmente de solo lectura. El SQL final que llega al
motor local pasa una barrera de defensa en profundidad que tokeniza y rechaza
cualquier cosa que no sea un único SELECT —como una violación de contrato,
no un error del usuario, porque el compilador nunca debería haberlo producido.
El sustrato de SQLite se ejecuta con las funciones integradas peligrosas
desactivadas, y las escrituras en DynamoDB tienen exactamente una ruta en toda
la app: el área de preparación revisable, que el SQL no puede
alcanzar.
Qué se transfiere si estás construyendo uno
- No escribas un motor de consultas; escribe un compilador fiel hacia uno que ya existe. Tus bugs vivirán en las costuras (mayúsculas de identificadores, envoltorios de tipos, filas corruptas), no en el algoritmo de unión.
- Exige a tu parser la procedencia de las comillas. Si no puede distinguir
namede"NAME", no puedes implementar la semántica de mayúsculas de SQL, y tus usuarios se darán cuenta antes que tú. - Haz de la «sintaxis no gestionada» un error ruidoso, nunca un valor por
defecto. El
NULLsilencioso es como se lanzan las respuestas incorrectas con seguridad. - Compara tu diseño con el script desechable que tu usuario escribiría. Si el script transmite y tú almacenas en búfer, construiste la forma equivocada.
- Trata los agregados parciales como respuestas incorrectas que necesitan etiquetado, no como crédito parcial.
La documentación del Workbench cubre la superficie de
funcionalidades del día a día, y SQL para DynamoDB
mapea lo que funciona en todo el ecosistema. O simplemente descarga
DynoTable, abre una pestaña del Workbench con ⌘⌥Q, y
ejecuta un JOIN contra la base de datos que no tiene ninguno.


