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 apost.authordevuelve 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 apost.authordevuelve 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:
test_query_with_eager_loading-- uso básico de.with()test_query_with_multiple_relations-- múltiples llamadas a.with()test_where_ref_basic-- filtrado de referenciastest_where_list_contains-- pertenencia a lista de enterostest_where_list_contains_string-- pertenencia a lista de cadenastest_resolve_list_references-- resolución de listas por lotetest_ref_field_auto_indexed-- verificación de indexación automáticatest_where_ref_uses_index-- utilización de índice para consultas de referenciatest_find_referencing-- consulta inversatest_has_references-- verificación booleana de referenciastest_count_references-- conteo de referenciastest_where_ref_null_and_not_null-- filtrado de referencias nulastest_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