Back to flin
flin

Relaciones y carga Eager/Lazy

Cómo FlinDB gestiona las relaciones entre entidades con carga eager, resolución lazy, consultas inversas y auto-indexación -- todo sin joins SQL.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 10 min flin
EN/ FR/ ES
flinrust

El problema más difícil en la abstracción de bases de datos no es almacenar datos. Es navegar relaciones. Una aplicación de blog tiene Usuarios que escriben Publicaciones que tienen Comentarios de otros Usuarios. Una aplicación de comercio electrónico tiene Productos en Categorías, comprados a través de Pedidos que contienen Artículos de Pedido. Toda aplicación real es un grafo de entidades interconectadas.

SQL resuelve esto con joins -- potentes pero verbosos. Los ORMs lo resuelven con carga lazy -- conveniente pero plagada de problemas de consultas N+1. FlinDB toma un enfoque diferente: declaraciones de relaciones explícitas, indexación automática y estrategias de carga controladas que hacen fácil lo correcto y difícil lo incorrecto.

La Sesión 164 fue donde las relaciones se hicieron reales. Diez funcionalidades, trece pruebas, novecientas setenta y dos líneas de Rust. Al final, FlinDB podía cargar entidades relacionadas de forma eager o lazy, consultar a través de relaciones, encontrar referencias inversas y auto-indexar claves foráneas -- todo sin una sola palabra clave JOIN.

Declarando relaciones

En FlinDB, las relaciones se declaran como campos tipados en las definiciones de entidades:

flinentity User {
    name: text
    email: text
}

entity Post {
    title: text
    body: text
    author: User           // One-to-many: each Post has one author
}

entity Tag {
    name: text
}

entity Article {
    title: text
    tags: [Tag]            // Many-to-many: each Article has multiple Tags
}

La declaración author: User crea una referencia de Post a User. ZeroCore almacena esto internamente como un ID de entidad, pero el desarrollador FLIN trabaja con objetos de entidad directamente:

flinuser = User { name: "Thales" }
save user

post = Post { title: "Hello", body: "...", author: user }
save post

// Access the relationship
post.author.name    // "Thales"

Sin columna de clave foránea. Sin tabla de join. Sin user_id INTEGER REFERENCES users(id). La relación se declara en la definición de la entidad y se usa naturalmente en el código.

Carga Eager con .with()

El problema de rendimiento más común con el manejo de relaciones al estilo ORM es el problema de consultas N+1. Cargas N publicaciones, luego para cada publicación cargas el autor -- resultando en 1 + N consultas a la base de datos. FlinDB resuelve esto con carga eager explícita:

rust// Load posts with their authors in a single pass
let results = db.query("Post")
    .with("author")
    .execute()?;

La llamada a .with("author") le dice al constructor de consultas que resuelva la referencia author para cada Post en el conjunto de resultados. En lugar de N búsquedas separadas, ZeroCore resuelve todas las referencias en un lote después de la consulta inicial:

rust// In execute_query(), after collecting results:
if !self.eager_load_fields.is_empty() {
    for entity in &mut results {
        for field_name in &self.eager_load_fields {
            if let Some(Value::EntityRef(ref_type, ref_id)) = entity.fields.get(field_name) {
                if let Ok(Some(referenced)) = db.find_by_id_internal(ref_type, *ref_id) {
                    entity.resolved_refs.insert(field_name.clone(), referenced);
                }
            }
        }
    }
}

Se pueden cargar múltiples relaciones simultáneamente:

rustdb.query("Post")
    .with("author")
    .with("category")
    .execute()?;

Y el método .with_all() carga cada campo EntityRef en el esquema:

rustdb.query("Post")
    .with_all()
    .execute()?;

Esto resuelve author, category y cualquier otro campo de referencia -- útil cuando necesitas el grafo completo de la entidad para una vista de detalle.

Consultas de referencia

Consultar a través de relaciones es una de las funcionalidades más potentes de FlinDB. El método .where_ref() filtra entidades por su entidad referenciada:

rust// Find all posts by a specific author
let user_posts = db.query("Post")
    .where_ref("author", user_id)
    .execute()?;

Bajo la superficie, .where_ref() es un envoltorio semántico alrededor de .where_eq() que opera sobre el ID almacenado del campo de referencia:

rustpub fn where_ref(mut self, field: &str, ref_id: u64) -> Self {
    self.conditions.push(QueryCondition::Eq {
        field: field.to_string(),
        value: Value::EntityRef("_".to_string(), ref_id),
    });
    self
}

Debido a que los campos de referencia se indexan automáticamente (más sobre esto abajo), las consultas de referencia se benefician de búsquedas de índice O(1). Encontrar todas las publicaciones de un autor específico es una búsqueda en índice, no un escaneo de tabla.

El filtrado de referencias nulas también está soportado:

rust// Posts with an author assigned
db.query("Post")
    .where_ref_not_null("author")
    .execute()?;

// Posts without an author (drafts, maybe)
db.query("Post")
    .where_ref_is_null("author")
    .execute()?;

Consultas de pertenencia a listas

Para relaciones muchos a muchos (listas de referencias), FlinDB proporciona .where_list_contains():

flinentity Article {
    title: text
    tags: [Tag]
}

// Find articles that have a specific tag
tagged = Article.where_list_contains("tags", tag_id)

La implementación en Rust verifica si el campo de lista de la entidad contiene el valor especificado:

rustpub fn where_list_contains(mut self, field: &str, value: Value) -> Self {
    self.conditions.push(QueryCondition::ListContains {
        field: field.to_string(),
        value,
    });
    self
}

Durante la ejecución, la condición parsea el formato de almacenamiento de listas y verifica la pertenencia:

rustQueryCondition::ListContains { field, value } => {
    if let Some(stored) = entity.fields.get(field) {
        match stored {
            Value::Text(s) if s.starts_with("__LIST__:") => {
                let ids: Vec<&str> = s[9..].split(',').collect();
                // Check if value is in the list
            }
            _ => false,
        }
    } else {
        false
    }
}

Las listas se almacenan como texto codificado internamente (__LIST__:1,2,3 para listas de enteros, __LIST_STR__:admin,editor para listas de cadenas). Esta codificación permite que las listas se almacenen como un único valor de campo manteniéndolas consultables.

Consultas inversas

A veces necesitas navegar una relación en reversa. Dado un User, encontrar todas las Posts que referencian a ese User. FlinDB proporciona tres métodos de consulta inversa:

rust// Find all entities that reference a specific entity
let posts = db.find_referencing("Post", "author", user_id)?;

// Check if any references exist (boolean)
if db.has_references("Post", "author", user_id)? {
    // Cannot delete user -- they have posts
}

// Count references
let post_count = db.count_references("Post", "author", user_id)?;

Estos métodos son esenciales para las operaciones en cascada y la integridad referencial. Antes de eliminar un User, FlinDB verifica has_references() para cada tipo de entidad que podría referenciar Users. Si existen referencias y el comportamiento ON DELETE es RESTRICT, la eliminación se bloquea.

La implementación de find_referencing() usa el índice en el campo de referencia para eficiencia:

rustpub fn find_referencing(
    &self,
    entity_type: &str,
    ref_field: &str,
    ref_id: u64,
) -> DatabaseResult<Vec<EntityInstance>> {
    // Uses index on ref_field for O(1) lookup
    self.query(entity_type)
        .where_ref(ref_field, ref_id)
        .execute()
}

Indexación automática de referencias

Una de las funcionalidades de mayor impacto de la Sesión 164 fue la indexación automática de campos de referencia de entidades. Cuando un esquema se registra con un campo de referencia, ZeroCore automáticamente agrega ese campo a la lista de campos indexados:

rust// During schema registration
for field_def in &schema.fields {
    if matches!(field_def.field_type, FieldType::EntityRef(_)) {
        if !schema.indexed_fields.contains(&field_def.name) {
            schema.indexed_fields.push(field_def.name.clone());
        }
    }
}

Esta es una optimización crítica que no requiere esfuerzo del desarrollador. Sin ella, cada consulta de referencia sería un escaneo completo de tabla. Con ella, Post.where(author == user) es una búsqueda de índice O(1).

La indexación automática se verificó con una prueba dedicada:

rust#[test]
fn test_ref_field_auto_indexed() {
    let db = ZeroCore::new();
    let schema = EntitySchema::new("Post")
        .field("title", FieldType::String)
        .field("author", FieldType::EntityRef("User".to_string()));
    db.register_schema("Post", schema);

    // Verify that "author" was automatically added to indexed_fields
    let registered = db.get_schema("Post").unwrap();
    assert!(registered.indexed_fields.contains(&"author".to_string()));
}

Resolución de listas por lote

Para relaciones muchos a muchos, resolver una lista completa de referencias es una operación común. FlinDB proporciona resolve_list_references() para resolución por lote:

rustpub fn resolve_list_references(
    &self,
    entity_type: &str,
    entity_id: u64,
    field_name: &str,
    target_type: &str,
) -> DatabaseResult<Vec<EntityInstance>> {
    let entity = self.find_by_id(entity_type, entity_id)?;

    if let Some(Value::Text(list_str)) = entity.fields.get(field_name) {
        if list_str.starts_with("__LIST__:") {
            let ids: Vec<u64> = list_str[9..]
                .split(',')
                .filter_map(|s| s.parse().ok())
                .collect();

            let mut results = Vec::new();
            for id in ids {
                if let Ok(target) = self.find_by_id(target_type, id) {
                    results.push(target);
                }
            }
            return Ok(results);
        }
    }

    Ok(vec![])
}

Esto resuelve todas las referencias en un campo de lista a sus instancias de entidad completas. Para un Article con tags: [Tag] conteniendo tres IDs de tags, resolve_list_references() devuelve tres entidades Tag completas.

La decisión de estrategia de carga

FlinDB no tiene carga lazy implícita. Esta es una decisión de diseño deliberada.

En ORMs como ActiveRecord o Hibernate, acceder a una propiedad de relación dispara una consulta implícita a la base de datos. Esto es conveniente pero peligroso. Una plantilla que renderiza una lista de publicaciones, cada una mostrando el nombre del autor, ejecuta silenciosamente N+1 consultas. El desarrollador no ve las consultas. La degradación de rendimiento es invisible hasta que la aplicación se arrastra bajo carga.

FlinDB requiere decisiones de carga explícitas:

  • Sin llamada a .with(): Los campos de referencia contienen el ID crudo. Acceder a post.author devuelve el ID de referencia, no la entidad completa. Sin consulta implícita.
  • Con llamada a .with("author"): Los campos de referencia se resuelven a entidades completas. Acceder a post.author devuelve la entidad User completa.
  • Con llamada a .with_all(): Todos los campos de referencia se resuelven. Cada relación se carga.

Esto obliga al desarrollador a pensar en su patrón de acceso a datos en el momento de la consulta. Es ligeramente menos conveniente que la carga lazy, pero elimina toda una categoría de bugs de rendimiento. Siempre sabes exactamente cuántas búsquedas de base de datos realizará una consulta, porque las especificaste explícitamente.

Las trece pruebas

La Sesión 164 agregó trece pruebas cubriendo cada funcionalidad de relaciones:

  1. test_query_with_eager_loading -- uso básico de .with()
  2. test_query_with_multiple_relations -- múltiples llamadas a .with()
  3. test_where_ref_basic -- filtrado de referencias
  4. test_where_list_contains -- pertenencia a lista de enteros
  5. test_where_list_contains_string -- pertenencia a lista de cadenas
  6. test_resolve_list_references -- resolución de listas por lote
  7. test_ref_field_auto_indexed -- verificación de indexación automática
  8. test_where_ref_uses_index -- utilización de índice para consultas de referencia
  9. test_find_referencing -- consulta inversa
  10. test_has_references -- verificación booleana de referencias
  11. test_count_references -- conteo de referencias
  12. test_where_ref_null_and_not_null -- filtrado de referencias nulas
  13. test_with_all -- cargar todas las relaciones

El total después de la Sesión 164: 2.133 pruebas (1.527 de biblioteca + 606 de integración). Novecientas setenta y dos líneas de código, todas en zerocore.rs. Esta fue una de las sesiones más densas en el desarrollo de FlinDB -- casi mil líneas de lógica de relaciones en una sola sesión.

Más allá de las relaciones simples

La Sesión 164 llevó las relaciones del 25% al 60% de completitud. El trabajo restante -- carga eager anidada (.with("author.company")), comportamiento en cascada a través de relaciones y restricciones de relaciones -- se abordaría en sesiones posteriores.

Pero la base era sólida. Las entidades podían referenciar otras entidades. Las referencias se indexaban automáticamente. Las consultas podían filtrar, cargar y contar a través de relaciones. Y el problema N+1 se resolvió por diseño -- no con una optimización inteligente, sino con una API que hace imposibles las consultas implícitas.


Esta es la Parte 7 de la serie "Cómo construimos FlinDB", documentando cómo construimos un motor de base de datos embebido completo para el lenguaje de programación FLIN.

Navegación de la serie: - [060] Aggregations and Analytics - [061] Index Utilization: Making Queries Fast - [062] Relationships and Eager/Lazy Loading (estás aquí) - [063] Transactions and Continuous Backup - [064] Graph Queries and Semantic Search

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

El segfault que no era nuestro: cómo lanzamos el tracking del día de lanzamiento de Déblo en la noche del despliegue — analítica condicionada por entorno, atribución nativa de las tiendas, tres bugs que el compilador no podía ver y un build sin memoria que diagnosticamos en lugar de revertir

El 1 de julio de 2026 — el día del lanzamiento — el riesgo nunca fue el texto. Era que las campañas de pago salieran a ciegas. Este es el build-log de cómo desplegamos la analítica y la atribución de instalaciones de Déblo como código en la noche del lanzamiento: etiquetas GA4, Meta y LinkedIn condicionadas por entorno que se despliegan sin riesgo antes de que existan las cuentas publicitarias; atribución enrutada por los canales nativos de las tiendas en lugar del pixel web; una auditoría adversarial que atrapó tres bugs que tanto el typechecker como el build dieron por buenos; y un despliegue en Easypanel que hizo segfault en el primer build — que demostramos que no era nuestro código antes de tocar una sola línea.

18 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26
Thales & Claude thales

Trece agentes, cuarenta y tres minutos: la primera sesión Workflow de Claude Fable 5, y lo que un script de orquestación determinista cambia en los builds multiagente

Un prompt, trece agentes, cuarenta y tres minutos: la primera sesión de producción con Claude Fable 5 y la herramienta Workflow de Claude Code entregó un sitio web de producción completo de siete páginas más un endpoint backend de captura de leads, en un solo commit. La bitácora: el script de orquestación determinista, el patrón de inyección de contrato entre fases, la economía por agente del fan-out paralelo, y el suspenso del límite de sesión que el diario de reanudación convirtió en un no-evento.

23 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

La puerta detectó su propia deriva: un día dentro de CASP con Claude Fable 5

Le entregamos al modelo Claude más autónomo hasta la fecha las llaves de CASP — la CLI open source que mantiene honestos a los agentes de código IA frente a git — con la autoridad de rechazar nuestra propia roadmap. Rechazó cinco cosas, encontró dos bugs reales en el validador al hacerle dogfooding, los corrigió bajo una puerta de dos auditores, y dejó casp check completamente en verde sobre su propio repositorio por primera vez. CASP 0.3.0 es el resultado.

15 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9