Una base de datos que solo puede encontrar registros es media base de datos. La otra mitad es responder preguntas sobre los registros: ¿Cuántos pedidos se realizaron este mes? ¿Cuál es el valor promedio de pedido? ¿Qué categoría tiene los mayores ingresos? Estas son consultas de agregación, y son la base de cada panel de control, informe y funcionalidad analítica en cada aplicación.
SQL maneja las agregaciones con SUM(), AVG(), COUNT(), GROUP BY y HAVING. Estas funciones son potentes pero requieren escribir cadenas SQL -- que, en el mundo de FLIN, es exactamente el tipo de complejidad accidental que nos propusimos eliminar.
La Sesión 162 agregó soporte completo de agregación a FlinDB. Seis conjuntos de funcionalidades. Doce pruebas. Trescientas cincuenta líneas de Rust. En dos horas, FlinDB pasó de ser un almacén CRUD a un motor analítico.
Las funciones de agregación
FlinDB soporta cuatro funciones de agregación principales, cada una operando sobre un campo nombrado a través de todas las entidades (o un subconjunto filtrado).
flin// Sum: total of a numeric field
total_revenue = Order.sum("total") // 975.0
// Average: mean of a numeric field
avg_rating = Review.avg("rating") // 4.2
// Minimum: smallest value
cheapest = Product.min("price") // 25
// Maximum: largest value
most_expensive = Product.max("price") // 1500Cada función devuelve un único valor numérico. Operan sobre las entidades actuales (no eliminadas) de un tipo, extrayendo el campo nombrado y computando la agregación.
La implementación en Rust sigue un patrón consistente. Aquí está sum:
rustpub fn sum(&self, entity_type: &str, field: &str) -> DatabaseResult<f64> {
let collection = self.data.get(entity_type)
.ok_or(DatabaseError::EntityTypeNotFound)?;
let mut total = 0.0;
for versions in collection.values() {
if let Some(entity) = versions.last() {
if entity.deleted_at.is_some() { continue; }
if let Some(value) = entity.fields.get(field) {
match value {
Value::Int(n) => total += *n as f64,
Value::Number(n) => total += *n,
_ => {} // Non-numeric fields are skipped
}
}
}
}
Ok(total)
}El manejo de casos límite es importante. Los valores no numéricos se omiten silenciosamente en lugar de causar errores. Una entidad con un campo faltante contribuye cero a la suma. Una colección vacía devuelve 0.0 para sum y NaN para average. Estas semánticas coinciden con el comportamiento de SQL, que los desarrolladores ya esperan.
Probamos el caso límite del conjunto vacío explícitamente:
rust#[test]
fn test_aggregation_empty_set() {
let db = ZeroCore::new();
db.register_schema("Product", schema);
// No entities saved
assert_eq!(db.sum("Product", "price").unwrap(), 0.0);
assert!(db.avg("Product", "price").unwrap().is_nan());
}GROUP BY: agregaciones por categoría
Las agregaciones simples responden preguntas simples. GROUP BY responde preguntas relacionales: "¿Cuál es el ingreso total por categoría?" o "¿Cuál es el salario promedio por departamento?"
FlinDB implementa GROUP BY a través de métodos dedicados que combinan agrupación y agregación:
flin// Revenue by category
revenue_by_category = Order.group_sum("category", "total")
// Returns: {"electronics": 15000, "books": 3000, "clothing": 8000}
// Average salary by department
avg_salary = Employee.group_avg("department", "salary")
// Returns: {"engineering": 95000, "design": 85000, "sales": 75000}
// Cheapest product per category
min_price = Product.group_min("category", "price")
// Most expensive product per category
max_price = Product.group_max("category", "price")Los métodos de agregación por grupo devuelven un HashMap de clave de grupo a valor agregado. La implementación en Rust recolecta valores en buckets, luego computa la agregación por bucket:
rustpub fn group_sum(
&self,
entity_type: &str,
group_field: &str,
sum_field: &str,
) -> DatabaseResult<HashMap<String, f64>> {
let collection = self.data.get(entity_type)
.ok_or(DatabaseError::EntityTypeNotFound)?;
let mut groups: HashMap<String, f64> = HashMap::new();
for versions in collection.values() {
if let Some(entity) = versions.last() {
if entity.deleted_at.is_some() { continue; }
let group_key = entity.fields.get(group_field)
.map(|v| v.to_string())
.unwrap_or_default();
let value = match entity.fields.get(sum_field) {
Some(Value::Int(n)) => *n as f64,
Some(Value::Number(n)) => *n,
_ => 0.0,
};
*groups.entry(group_key).or_insert(0.0) += value;
}
}
Ok(groups)
}La clave de grupo se convierte a cadena para la clave del HashMap. Esto funciona para campos de texto, entero y booleano -- cubriendo la gran mayoría de los casos de uso de GROUP BY. Para agrupaciones complejas (grupos multi-campo o agregaciones anidadas), el constructor de consultas proporciona más flexibilidad.
DISTINCT: valores únicos
La operación DISTINCT devuelve valores únicos de un campo a través de todas las entidades:
flincategories = Product.distinct("category")
// Returns: ["electronics", "books", "clothing"]Esto es esencial para construir UIs de filtro ("muéstrame todas las categorías disponibles"), generar informes ("listar todos los departamentos") y validación de datos ("¿qué valores existen para este campo?").
La implementación recolecta valores únicos en un conjunto:
rustpub fn distinct(
&self,
entity_type: &str,
field: &str,
) -> DatabaseResult<Vec<Value>> {
let collection = self.data.get(entity_type)
.ok_or(DatabaseError::EntityTypeNotFound)?;
let mut seen = HashSet::new();
let mut result = Vec::new();
for versions in collection.values() {
if let Some(entity) = versions.last() {
if entity.deleted_at.is_some() { continue; }
if let Some(value) = entity.fields.get(field) {
let key = value.to_string();
if seen.insert(key) {
result.push(value.clone());
}
}
}
}
Ok(result)
}IN y NOT IN: pertenencia a conjuntos
Los operadores IN y NOT IN de SQL filtran filas basándose en pertenencia a conjuntos. FlinDB proporciona la misma capacidad a través de métodos del constructor de consultas:
flin// IN: find orders with specific statuses
active = Order.where_in("status", ["pending", "processing"])
// NOT IN: exclude cancelled and refunded orders
valid = Order.where_not_in("status", ["cancelled", "refunded"])Estos operadores se implementan como condiciones de consulta que verifican si el valor de un campo está presente (o ausente) en una lista proporcionada:
rustpub fn where_in(
mut self,
field: &str,
values: Vec<Value>,
) -> Self {
self.conditions.push(QueryCondition::In {
field: field.to_string(),
values,
});
self
}
pub fn where_not_in(
mut self,
field: &str,
values: Vec<Value>,
) -> Self {
self.conditions.push(QueryCondition::NotIn {
field: field.to_string(),
values,
});
self
}Durante la ejecución de la consulta, In verifica values.contains(&entity_value) y NotIn verifica !values.contains(&entity_value). Para listas de valores pequeñas (el caso común), esto es eficiente. Para listas grandes, el optimizador de consultas podría cambiar a un HashSet -- pero en la práctica, las cláusulas IN con más de una docena de valores son raras en código de aplicación.
ORDER BY múltiple
La Sesión 162 también agregó soporte para múltiples criterios de ordenamiento -- una funcionalidad que es trivial en SQL (ORDER BY category ASC, price DESC) pero requiere implementación cuidadosa en un motor no SQL:
flinsorted = Product
.order_by_asc("category")
.order_by_desc("price")Esto ordena los productos alfabéticamente por categoría, luego dentro de cada categoría por precio descendente. La implementación acumula campos de ordenamiento y los aplica como un ordenamiento multi-clave:
rustpub fn order_by_asc(mut self, field: &str) -> Self {
self.orderings.push((field.to_string(), SortDirection::Asc));
self
}
pub fn order_by_desc(mut self, field: &str) -> Self {
self.orderings.push((field.to_string(), SortDirection::Desc));
self
}Durante la ejecución, el conjunto de resultados se ordena usando sort_by de Rust con un comparador que evalúa los campos de ordenamiento en secuencia:
rustresults.sort_by(|a, b| {
for (field, direction) in &self.orderings {
let val_a = a.fields.get(field);
let val_b = b.fields.get(field);
let cmp = compare_values(val_a, val_b);
match direction {
SortDirection::Asc => {
if cmp != Ordering::Equal { return cmp; }
}
SortDirection::Desc => {
if cmp != Ordering::Equal { return cmp.reverse(); }
}
}
}
Ordering::Equal
});Hacer que esto funcionara requirió una corrección de bug: la comparación de Value::Text no estaba implementada. Los valores de texto no podían ordenarse alfabéticamente porque el método less_than() en Value no manejaba la variante Text. La Sesión 162 agregó la comparación faltante:
rust(Value::Text(a), Value::Text(b)) => Some(a < b)Dos líneas de código. Pero sin ellas, ordenar por cualquier campo de texto produciría resultados indefinidos. Este es el tipo de bug sutil que solo aparece cuando pruebas escenarios del mundo real -- ordenar productos por nombre de categoría en lugar de por precio.
Un ejemplo de analítica del mundo real
Para demostrar la capacidad analítica completa, creamos un ejemplo de analítica de comercio electrónico que ejercita cada funcionalidad de agregación:
flinentity Product {
name: text
category: text
price: number
sales: int
}
// Aggregate queries
total_revenue = Product.sum("sales")
avg_price = Product.avg("price")
cheapest = Product.min("price")
most_expensive = Product.max("price")
// Group analytics
revenue_by_category = Product.group_sum("category", "sales")
avg_price_by_category = Product.group_avg("category", "price")
// Filtered aggregates
electronics = Product.where(category == "electronics")
electronics_revenue = electronics.sum("sales")
// Distinct values
categories = Product.distinct("category")
// Complex query
top_products = Product
.where_not_in("category", ["discontinued"])
.order_by_desc("sales")
.limit(10)Este ejemplo se lee como un documento de requisitos de un analista de negocio. Sin SQL. Sin JOIN. Sin cláusula GROUP BY con alias de columnas. Solo métodos que dicen lo que hacen.
Las doce pruebas
La Sesión 162 agregó doce pruebas cubriendo cada funcionalidad de agregación:
test_aggregation_sum-- suma de campo numéricotest_aggregation_avg-- promedio de campo numéricotest_aggregation_min_max-- mínimo y máximotest_aggregation_empty_set-- caso límite sin entidadestest_group_sum-- GROUP BY con SUMtest_group_avg-- GROUP BY con AVGtest_group_min_max-- GROUP BY con MIN/MAXtest_distinct-- extracción de valores únicostest_where_in-- operador INtest_where_not_in-- operador NOT INtest_multiple_order_by-- ordenamiento multi-campotest_clear_order-- funcionalidad de limpieza de orden
El conteo total de pruebas después de la Sesión 162: 2.111 (1.505 de biblioteca + 606 de integración). Cada prueba validó no solo el camino feliz sino casos límite: colecciones vacías, campos no numéricos, valores nulos y estabilidad de ordenamiento.
Lo que vino después
La Sesión 162 llevó el motor de consultas de FlinDB a paridad de funcionalidades con la analítica SQL básica. Pero quedaban dos brechas críticas:
Los índices no se utilizaban. La anotación @index existía en la definición del esquema, pero todas las consultas aún realizaban escaneos completos de tabla. Para una base de datos con unos pocos miles de entidades, esto era suficientemente rápido. Para cargas de trabajo de producción con decenas de miles de entidades, las consultas O(n) se convertirían en un cuello de botella. La Sesión 163 lo corregiría implementando estructuras de datos de índice reales e integrándolas en la ruta de ejecución de consultas.
Las relaciones no eran consultables. resolve_reference() existía pero no estaba integrado con el constructor de consultas. No podías escribir Post.where(author == user) y que resolviera la relación. La Sesión 164 agregaría carga eager, consultas de referencia y consultas inversas.
El motor de agregación fue la última pieza de la base de consultas. Con CRUD, restricciones y analítica en su lugar, FlinDB tenía la expresividad de una base de datos de producción. El siguiente desafío era hacerla rápida.
Esta es la Parte 5 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: - [056] FlinDB: Zero-Configuration Embedded Database - [057] Entities, Not Tables: How FlinDB Thinks About Data - [058] CRUD Without SQL - [059] Constraints and Validation in FlinDB - [060] Aggregations and Analytics (estás aquí)