Saltar al contenido
Framework Ecosystems LabsUn contrato, muchos ecosistemas, la misma prueba.

Clase 063 — Bases no relacionales#

⬅️ 062 · 📚 Parte 4 · 🎓 Clases · 064 ➡️ Parte 4 — Datos · Nivel 🟡 intermedio · Pista datosClase construida — 3 implementaciones verificadas contra contrato.json.

🎯 Objetivo#

Modelar el mismo caso sin tablas y ver qué cambia — lo que se gana, y lo que se paga.

⚠️ Lo que esta clase NO es#

Aquí no hay MongoDB ni Cassandra. El documento se guarda en una columna de texto de SQLite o de H2, y las tres implementaciones lo tratan como un documento: se escribe entero, se lee entero y se busca dentro de él.

Eso permite enseñar lo que de verdad decide el modelado —incrustar o referenciar, y el esquema mudándose de la base al código— sin montar un servidor.

Y deja fuera cosas que una base documental de verdad sí trae, y que conviene no suponer:

Decirlo es parte del trato: un verde aquí significa lo que se probó.

🧩 La situación#

Una tarea con sus etiquetas y su autor dentro. Un segundo documento con otra forma. Y un cambio en el autor.

<!-- generado: fichas -->

🧰 Las piezas de esta clase, una por una#

Antes del código: qué es cada framework, qué versión se está usando y qué hace falta para ejecutarlo. Todo lo de esta sección sale de los archivos reales del repositorio —el catálogo, la receta de arranque y el manifiesto de dependencias de cada ecosistema—, así que no puede quedarse desactualizado sin que la validación lo detecte.

Framework Qué es Desde Licencia Quién lo mantiene
Prisma ORM mapeador objeto-relacional de JavaScript/TypeScript (TypeScript) 2021 Apache-2.0 proyecto independiente
SQLAlchemy mapeador objeto-relacional de Python (Python) 2006 MIT proyecto independiente
Hibernate ORM mapeador objeto-relacional de JVM (Java) 2001 LGPL-2.1-or-later proyecto independiente

🔧 Prisma ORM#

Esquema propio del que se genera un cliente tipado. Un lenguaje más que aprender, a cambio de tipos exactos.

Preparar sus dependencias, dentro de su directorio:

pnpm install --silent --ignore-scripts
pnpm exec prisma generate

Arrancarla suelta, sin el verificador:

PORT=3000 node server.mjs

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
package.json manifiesto de Node.js: nombre, tipo de módulo y dependencias con su rango de versión
pnpm-lock.yaml archivo de bloqueo: la versión exacta de cada dependencia y de sus dependencias
pnpm-workspace.yaml raíz de instalación propia, y la prohibición de ejecutar scripts al instalar
prisma/schema.prisma esquema de Prisma: el modelo de datos del que se genera el cliente
server.mjs código JavaScript (módulo ES)

🔧 SQLAlchemy#

Separa explícitamente el constructor de consultas del mapeador, de modo que se puede bajar de nivel sin abandonarlo.

Arrancarla suelta, sin el verificador:

PORT=3000 python -m uvicorn main:app --host 127.0.0.1 --port 3000

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
main.py código Python
requirements.txt dependencias de Python, una por línea, con versión fijada

🔧 Hibernate ORM#

El mapeador objeto-relacional de referencia en Java y el origen de buena parte del vocabulario del campo, incluido el problema de la consulta N+1.

Preparar sus dependencias, dentro de su directorio:

mvn -q -B package -DskipTests

Arrancarla suelta, sin el verificador:

PORT=3000 java -jar target/clase-063-1.0.0.jar --server.port=3000

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
pom.xml manifiesto de Maven: el proyecto, su Java, sus dependencias y cómo se empaqueta
src/main/java/labs/Aplicacion.java código Java
src/main/resources/application.properties configuración de Spring Boot: lo que se ajusta sin tocar el código

Si alguna cadena de herramientas no está en tu máquina, node scripts/doctor.mjs dice cuál falta y con qué comando se instala. No hace falta tenerlas todas: el verificador ejecuta lo que encuentra y declara lo que omitió.

<!-- fin generado: fichas -->

🌐 Las implementaciones — el código a la vista#

Las tres declaran la misma tabla de dos columnas, y las tres exponen /esquema para poder demostrarlo. Lo interesante no es que funcione: es lo que cuesta, y dónde lo cobra cada motor.

Prisma · prisma/server.mjs y prisma/schema.prisma#

Todo el esquema:

model Documento {
  id        Int    @id @default(autoincrement())
  documento String
}

Ni titulo, ni etiquetas, ni autor. La forma de una tarea no está en la base: está en cada documento.

Guardar y leer es serializar:

  const creado = await prisma.documento.create({
    data: { documento: JSON.stringify(cuerpo) },
  });
  respuesta.json({ id: fila.id, ...JSON.parse(fila.documento) });

Una lectura, sin uniones, porque no hay nada que unir. Ese es el argumento entero a favor del documento: lo que se lee junto, se guarda junto.

Y el aviso que Prisma pone por escrito:

/**
 * SQLite NO TIENE TIPO JSON.
 *
 * Su soporte —la extensión JSON1— son funciones que operan sobre TEXTO:
 * `json_extract`, `json_each`, `json_set`. El documento se guarda como una
 * cadena y el motor sabe mirar dentro cuando se lo pides.
 *
 * Es una diferencia real con PostgreSQL, que sí tiene un tipo `jsonb` con su
 * propia representación binaria y sus propios índices.
 */

Buscar dentro del documento:

  const filas = await prisma.$queryRaw`
    SELECT DISTINCT d.id AS id
      FROM Documento d, json_each(d.documento, '$.etiquetas') e
     WHERE e.value = ${nombre}
     ORDER BY d.id`;

json_each convierte el array del documento en filas y a partir de ahí es SQL corriente. Fíjate en que hay que salir del ORM para escribirlo: es la clase 060 aplicada aquí, y por el motivo previsto — el ORM no cubre las funciones específicas del motor.

Cero campos declarados, y no es lo mismo que «sin esquema»:

  respuesta.json({
    columnas: columnas.map((c) => c.name).sort(),
    campos_declarados: 0,
  });

La base no sabe qué campos tiene una tarea. Eso no significa que no haya esquema: significa que el esquema está en el código y que nadie lo hace cumplir. La clase 057 lo hacía cumplir el motor; aquí lo hace cumplir la disciplina del equipo, que es una garantía distinta.

El coste de incrustar, en un bucle:

  let tocados = 0;
  for (const fila of filas) {
    const documento = JSON.parse(fila.documento);
    if (documento.autor?.correo !== correo) continue;
    documento.autor.nombre = nombre;

En el modelo relacional cambiar el nombre de un autor es UPDATE autores SET nombre = ... sobre una fila. Aquí no hay una fila: hay tantas copias como documentos. Esa es la factura de haber incrustado, y se paga en cada escritura que toca datos compartidos.

SQLAlchemy · sqlalchemy/main.py#

    __tablename__ = "documentos"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    documento: Mapped[dict[str, Any]] = mapped_column(JSON)

Con una comodidad que Prisma no da: JSON es un tipo de SQLAlchemy, no de SQLite. El ORM serializa y deserializa por ti, y en la base queda texto. Por eso aquí no hay ningún json.dumps a la vista.

    consulta = text("""
        SELECT DISTINCT d.id AS id
          FROM documentos d, json_each(d.documento, '$.etiquetas') e
         WHERE e.value = :nombre
         ORDER BY d.id
    """)

Y una trampa que merece la clase entera:

            contenido["autor"] = {**autor, "nombre": nombre}
            fila.documento = contenido
            # Se reasigna el diccionario ENTERO: SQLAlchemy no detecta cambios
            # dentro de una columna JSON salvo que se use `MutableDict`. Es una
            # trampa clasica y silenciosa — el cambio simplemente no se guarda.

Escribir fila.documento["autor"]["nombre"] = nombre no guarda nada. El ORM detecta cambios comparando la referencia del atributo, y la referencia no cambió: mutaste lo de dentro. No hay error, no hay aviso, y el commit termina bien. Se descubre al releer.

Hibernate · hibernate/…/Aplicacion.java — donde el motor no sabe mirar dentro#

    @Entity
    @Table(name = "documentos")
    public static class Doc {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        public Long id;

        @Lob
        @Column(nullable = false)
        public String documento = "";
    }

Sin tipo JSON en JPA estándar, así que el documento es un @Lob de texto y Jackson hace la conversión a mano:

    private static Map<String, Object> leerJson(String texto) {
        try {
            return JSON.readValue(texto, Map.class);

Y aquí la diferencia que decide si esto escala:

            List<Integer> ids = new ArrayList<>();
            for (Doc fila : documentos.findAll()) {
                Object etiquetas = leerJson(fila.documento).get("etiquetas");
                if (etiquetas instanceof List<?> lista && lista.contains(nombre)) {
                    ids.add(fila.id.intValue());
                }
            }

SQLite tiene json_each, PostgreSQL tiene los operadores de jsonb, H2 no tiene nada equivalente. Así que no queda más remedio que traerse todos los documentos y filtrarlos en memoria — exactamente el problema de la clase 060.

No es un descuido de la implementación: es lo que pasa cuando el motor no sabe mirar dentro del documento. Y de ahí la lección más útil de la clase: guardar JSON es fácil en cualquier base; consultarlo depende por completo de cuál sea.

Elegir «documentos» no es una decisión de modelo, es una decisión de motor.

🧮 El contrato#

Petición Respuesta
POST /tareas con etiquetas y autor 201
GET /tareas/1 todo entero, anidado
GET /consultas 1
POST /tareas con otra forma 201
GET /tareas/2 los campos que tenía
GET /esquema ["documento", "id"], campos_declarados: 0
GET /por-etiqueta?nombre=casa [1]
GET /por-etiqueta?nombre=oficina []
POST /renombrar-autor documentos_tocados: 2
GET /tareas/2 el nombre nuevo

📖 Lo que se gana: una lectura#

En la clase 055 esta misma tarea vivía en dos tablas y leerla costaba una unión. Con un autor sería una tercera.

Aquí es una consulta por clave primaria, y devuelve el árbol entero. No hay unión porque no hay nada que unir: lo que se lee junto está guardado junto.

Esa es la propuesta entera del modelo documental, y es una propuesta buena cuando la unidad de lectura y la unidad de escritura coinciden.

📖 Lo que se paga: la duplicación#

POST /renombrar-autor  →  { documentos_tocados: 2 }

El autor está incrustado en cada tarea. Cambiarle el nombre no es un UPDATE sobre una fila: es recorrer todas las tareas que lo llevan dentro.

Con dos documentos es anecdótico. Con dos millones, es un trabajo por lotes que tarda y que puede quedarse a medias — porque, además, no hay una transacción que cubra todos los documentos: la atomicidad de una base documental llega hasta el borde del documento.

Ese es el equilibrio de esta clase, y se resume en una pregunta:

¿Este dato lo lees siempre con su padre, o también por su cuenta?

El ejemplo incrusta el autor a propósito, para que el último caso del contrato muestre la factura.

📖 El esquema no desaparece: se muda#

campos_declarados: 0 no significa que no haya esquema. Los dos documentos tienen forma, el código la espera, y una respuesta sin titulo rompería al cliente igual que antes.

Lo que cambia es quién lo hace cumplir:

Relacional Documental
Dónde está en la base en el código
Quién lo aplica el motor, en cada escritura tú, al leer
Cambiar un campo una migración (clase 058) nada… y todo
Documentos viejos no existen: la migración los tocó siguen ahí, con la forma vieja

La última fila es la consecuencia práctica que más sorprende. Añadir un campo es gratis; leer pasa a serlo menos, porque hay que contar con documentos escritos por versiones anteriores del código. Se llama esquema en la lectura, y significa que el código de lectura acumula compatibilidad hacia atrás durante años [kleppmann-ddia].

No es mejor ni peor que una migración. Es la misma deuda, pagada en otro sitio y a plazos.

🔬 Comparación#

Prisma SQLAlchemy Hibernate
Cómo se declara String en el esquema tipo JSON de SQLAlchemy @Lob String
Serializar a mano, JSON.stringify lo hace el ORM a mano, con Jackson
Buscar dentro json_each de SQLite json_each de SQLite recorriendo en memoria
Trampa propia ninguna no detecta cambios dentro del diccionario ninguna

La fila que importa es la tercera, y merece su propia sección.

🔬 Buscar dentro del documento: aquí no empatan#

-- SQLite: json_each convierte el array del documento en filas
SELECT DISTINCT d.id FROM Documento d, json_each(d.documento, '$.etiquetas') e
 WHERE e.value = ?
// H2: no hay equivalente. Se traen TODOS y se filtran en memoria.
for (Doc fila : documentos.findAll()) { ... }

La misma clase, y una implementación escala y la otra no. No es un descuido del ejemplo: es lo que pasa cuando el motor no sabe mirar dentro de la columna.

Y explica por qué la elección aquí no es «relacional o documental», sino qué motor: PostgreSQL con jsonb indexa campos anidados y compite de tú a tú con una base documental; SQLite sabe recorrer pero no indexar dentro; H2 ni eso.

⚠️ Errores frecuentes#

✅ Verificación#

node scripts/run-class.mjs 063

🧪 Reto de transferencia#

Cambia el modelo para que el autor esté referenciado —un documento aparte y su identificador dentro de la tarea— y vuelve a ejecutar. renombrar-autor pasará a tocar un documento, y leer una tarea pasará a costar dos consultas. Has recreado el modelo relacional, sin tablas y a mano: esa es la lección.

🔗 Enlaces#

Fuentes#