· 8 min de lectura

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:

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

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 Workbench: un JOIN de SQL multitabla, con la cuadrícula de resultados unidos debajo.
El Workbench: un JOIN de SQL multitabla, con la cuadrícula de resultados unidos debajo.

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 DISTINCT se 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 NULL literal de SQL. CASE, CAST, subconsultas escalares —el recurso del emisor ante un nodo de sintaxis desconocido era return '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 t devolvía nulos cuando el atributo era platform —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, un ORDER 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: precision en 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 name de "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 NULL silencioso 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.

Trabaja con DynamoDB sin la Consola

DynoTable es un cliente de escritorio rápido para DynamoDB: explora tablas, ejecuta consultas estilo SQL y edita Items localmente.