Back to flin
flin

Restricciones y validación en FlinDB

Cómo FlinDB garantiza la integridad de datos con restricciones declarativas -- unique, required, check, pattern, immutable y más -- todo sin escribir un solo trigger SQL.

Thales & Claude | March 30, 2026 12 min flin
EN/ FR/ ES
flinrust

Una base de datos que acepta cualquier cosa es una base de datos que contiene basura. La integridad de datos no es una funcionalidad deseable -- es la base de la que depende cada capa de aplicación por encima de la base de datos. Si la base de datos permite crear un usuario sin email, cada pieza de código que lee usuarios debe verificar la ausencia de emails. Si la base de datos permite nombres de usuario duplicados, cada flujo de autenticación debe manejar colisiones a nivel de aplicación.

El mundo relacional resolvió esto hace décadas con restricciones: NOT NULL, UNIQUE, CHECK, FOREIGN KEY. Pero estas restricciones se definen en DDL SQL, se verifican a nivel de base de datos y reportan errores como códigos crípticos como 23505 unique_violation. Funcionan, pero no son amigables para el desarrollador.

FlinDB implementa un sistema de restricciones que es declarativo, legible y completo. La Sesión 161 fue la maratón que lo hizo realidad -- nueve tipos de restricciones, 31 pruebas y un sistema de cascada que maneja correctamente tanto la eliminación suave como la destrucción permanente.

Los tres niveles de restricciones

Organizamos las restricciones de FlinDB en tres niveles de prioridad según la frecuencia con que se necesitan.

P1: Restricciones esenciales

Estas son las restricciones que toda aplicación necesita. Sin ellas, la base de datos no está lista para producción.

Restricciones unique previenen valores duplicados en un campo:

flinentity User {
    email: text @unique
    username: text @unique
}

Cuando una operación de guardado encuentra un valor duplicado en un campo unique, ZeroCore lo rechaza con un error claro:

rustfn check_unique_constraints(
    &self,
    entity_type: &str,
    id: Option<u64>,
    schema: &EntitySchema,
    fields: &HashMap<String, Value>,
) -> DatabaseResult<()>

La verificación de unicidad tiene semánticas importantes que requirieron reflexión cuidadosa:

  • Value::None no participa en verificaciones unique. Múltiples entidades pueden tener none para un campo unique. Esto coincide con el comportamiento de SQL donde los valores NULL se consideran distintos.
  • Las entidades eliminadas de forma suave se excluyen de las verificaciones de unicidad. Si eliminas un usuario con email "[email protected]", puedes crear un nuevo usuario con ese mismo email.
  • Actualizar una entidad con el mismo valor que ya tiene está permitido. Establecer el email de un usuario a su valor actual no genera una violación de unicidad contra sí mismo.

Restricciones required previenen valores nulos:

flinentity User {
    name: text           // Required by default (not optional)
    email: text
    bio: text?           // Optional
}

En FlinDB, los campos sin el sufijo ? son requeridos por defecto. La validación verifica tanto campos faltantes como valores None explícitos:

rustif field_def.required {
    match fields.get(&field_def.name) {
        None | Some(Value::None) => {
            return Err(DatabaseError::MissingField {
                entity_type: entity_type.to_string(),
                field: field_def.name.clone(),
            });
        }
        _ => {}
    }
}

Aplicación de claves foráneas verifica que las entidades referenciadas existan:

flinentity Post {
    title: text
    author: User    // Must reference an existing User
}

// This works:
user = User.find(1)
save Post { title: "Hello", author: user }

// This fails:
save Post { title: "Hello", author: User { id: 999 } }
// Error: Referenced User with id 999 not found

Comportamiento ON DELETE controla qué sucede con las entidades que referencian cuando una entidad referenciada se elimina:

flinentity Comment {
    text: text
    post: Post @on_delete(cascade)    // Delete comments when post is deleted
}

entity Profile {
    bio: text
    user: User @on_delete(restrict)   // Cannot delete user with a profile
}

entity Assignment {
    task: text
    assignee: User @on_delete(set_null) // Set to null when user is deleted
}

La implementación de cascada fue una de las correcciones más críticas de la Sesión 161. El código original solo manejaba cascada para eliminación suave. Lo refactorizamos para manejar ambas operaciones:

rustfn handle_on_delete_or_destroy(
    &mut self,
    entity_type: &str,
    entity_id: u64,
    is_destroy: bool,
) -> DatabaseResult<()> {
    match behavior {
        OnDeleteBehavior::Cascade => {
            if is_destroy {
                self.destroy(&ref_type, id)?;
            } else {
                self.delete(&ref_type, id)?;
            }
        }
        OnDeleteBehavior::SetNull => {
            // Set the reference field to None
        }
        OnDeleteBehavior::Restrict => {
            return Err(DatabaseError::RestrictViolation { /* ... */ });
        }
    }
}

Cuando haces delete de un Post con cascade, sus Comments se eliminan de forma suave. Cuando haces destroy de un Post con cascade, sus Comments se destruyen permanentemente. La cascada propaga el tipo de operación, no solo la eliminación.

P2: Restricciones importantes

Estas restricciones manejan lógica de negocio que de otra manera viviría en el código de aplicación.

Restricciones check aplican condiciones arbitrarias:

flinentity Product {
    name: text
    price: number @check(price > 0)
    quantity: int @check(quantity >= 0)
}

La restricción @check evalúa una condición contra el valor del campo antes de guardar. Si la condición es falsa, el guardado se rechaza. Esto mueve la validación de la capa de aplicación -- donde es fácil olvidarla -- al modelo de datos donde siempre se aplica.

Campos condicionalmente requeridos se requieren solo cuando se cumple otra condición:

flinentity Order {
    delivery_type: text
    shipping_address: text @required_if(delivery_type == "shipping")
}

Si delivery_type es "shipping", entonces shipping_address debe estar presente. Si delivery_type es "pickup", shipping_address puede omitirse. Esto elimina toda una categoría de bugs de "campo requerido faltante" que solo aparecen en escenarios de negocio específicos.

Validación de patrones aplica reglas de formato:

flinentity User {
    email: text @pattern(email, "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$", "Invalid email format")
    phone: text @pattern(phone, "^\\+[0-9]{10,15}$", "Phone must start with + and contain 10-15 digits")
}

La restricción @pattern toma un nombre de campo, una expresión regular y un mensaje de error. Cuando la validación falla, el mensaje de error es legible para humanos -- no un volcado de patrón regex.

P3: Restricciones deseables

Estas restricciones abordan casos límite que surgen en aplicaciones maduras.

Restricciones unique parciales aplican unicidad solo cuando se cumple una condición:

flinentity User {
    email: text @unique_where(email, status == "active")
}

Esto permite que múltiples usuarios compartan una dirección de email, siempre que solo uno de ellos esté activo. Común en sistemas con flujos de desactivación/reactivación de cuentas.

Unique insensible a mayúsculas previene duplicados independientemente de las mayúsculas:

flinentity User {
    username: text @unique_ignore_case
}

"Thales", "THALES" y "thales" se consideran el mismo nombre de usuario. Sin esta restricción, los desarrolladores deben normalizar las mayúsculas en el código de aplicación -- un paso que es fácil olvidar en uno de los cincuenta lugares donde se crean o actualizan nombres de usuario.

Campos inmutables no pueden cambiarse después de la creación inicial:

flinentity Transaction {
    transaction_id: text @immutable
    amount: money @immutable
    created_at: time @immutable
}

Una vez que una transacción se guarda, sus campos transaction_id, amount y created_at no pueden modificarse. Cualquier intento de guardar con un valor cambiado se rechaza:

rustfn check_immutable_constraints(
    &self,
    entity_type: &str,
    entity_id: u64,
    schema: &EntitySchema,
    fields: &HashMap<String, Value>,
) -> DatabaseResult<()> {
    // Only applies to updates (existing entities)
    if let Some(current) = self.find_by_id_internal(entity_type, entity_id)? {
        for constraint in &schema.constraints {
            if let Constraint::Immutable(field) = constraint {
                let current_val = current.fields.get(field);
                let new_val = fields.get(field);
                if current_val != new_val {
                    return Err(DatabaseError::ImmutableViolation {
                        entity_type: entity_type.to_string(),
                        field: field.clone(),
                    });
                }
            }
        }
    }
    Ok(())
}

Restricciones unique compuestas

La unicidad de un solo campo no siempre es suficiente. Considera una aplicación de comercio electrónico donde un usuario puede tener múltiples direcciones, pero cada dirección tiene una etiqueta única por usuario:

flinentity Address {
    user: User
    label: text         // "Home", "Work", "Mom's house"
    street: text
    city: text
    @unique(user, label) // Unique combination of user + label
}

La restricción unique compuesta garantiza que ningún usuario pueda tener dos direcciones con la etiqueta "Home", mientras que diferentes usuarios pueden tener cada uno su propia dirección "Home".

ZeroCore implementa la unicidad compuesta verificando todas las combinaciones de campos:

rustConstraint::CompositeUnique(fields) => {
    let values: Vec<_> = fields.iter()
        .map(|f| new_fields.get(f).cloned())
        .collect();

    for (existing_id, versions) in collection {
        if Some(*existing_id) == id { continue; }
        if let Some(entity) = versions.last() {
            if entity.deleted_at.is_some() { continue; }
            let existing_values: Vec<_> = fields.iter()
                .map(|f| entity.fields.get(f).cloned())
                .collect();
            if values == existing_values {
                return Err(DatabaseError::CompositeUniqueViolation { /* ... */ });
            }
        }
    }
}

El pipeline de restricciones

Todas las restricciones se verifican en un único pipeline de validación durante save(). El orden importa:

  1. Campos requeridos -- se verifican primero porque otras restricciones no pueden validar datos faltantes
  2. Validación de tipos -- asegura que los valores de campos coincidan con sus tipos declarados
  3. Restricciones check -- evalúa condiciones @check
  4. Requerido condicional -- evalúa condiciones @required_if
  5. Validación de patrones -- evalúa coincidencias regex @pattern
  6. Restricciones unique -- verifica duplicados (simple, compuesto, parcial, insensible a mayúsculas)
  7. Restricciones inmutables -- previene modificación de campos bloqueados (solo actualizaciones)
  8. Validación de claves foráneas -- verifica que las entidades referenciadas existan

Si alguna restricción falla, el guardado se aborta y se devuelve un error descriptivo. Sin guardados parciales. Sin estado inconsistente. La entidad pasa todas las restricciones y se persiste, o falla la validación y nada cambia.

Mensajes de error que ayudan

Uno de los aspectos más frustrantes de las violaciones de restricciones SQL son los mensajes de error. PostgreSQL te da ERROR: duplicate key value violates unique constraint "users_email_key". MySQL te da ERROR 1062 (23000): Duplicate entry '[email protected]' for key 'users.email'. Estos mensajes son legibles por máquinas pero hostiles para humanos.

Los mensajes de error de FlinDB están diseñados para entenderse inmediatamente:

  • "User with email '[email protected]' already exists" (violación unique)
  • "User requires field 'name' but it was not provided" (violación required)
  • "Product price must satisfy: price > 0" (violación check)
  • "User email does not match pattern: Invalid email format" (violación de patrón)
  • "Transaction field 'amount' is immutable and cannot be changed" (violación inmutable)
  • "Cannot delete User: referenced by Post (restrict)" (violación restrict)

Estos mensajes nombran la entidad, el campo y la restricción en inglés sencillo. Un desarrollador que ve uno de estos errores sabe exactamente qué salió mal y qué corregir.

Probando el sistema de restricciones

La Sesión 161 agregó 31 pruebas para el sistema de restricciones. La estrategia de pruebas fue exhaustiva: cada tipo de restricción se probó tanto para la ruta de éxito (datos válidos aceptados) como para la ruta de fallo (datos inválidos rechazados).

Las pruebas de cascada fueron particularmente importantes porque el comportamiento en cascada tiene una explosión combinatoria de escenarios:

  • Eliminación suave con CASCADE: las entidades hijas se eliminan de forma suave
  • Destrucción permanente con CASCADE: las entidades hijas se destruyen permanentemente
  • Eliminación suave con RESTRICT: la eliminación se bloquea si existen hijos
  • Destrucción permanente con RESTRICT: la destrucción se bloquea si existen hijos
  • Eliminación suave con SET_NULL: el campo de referencia se establece a None
  • Destrucción permanente con SET_NULL: el campo de referencia se establece a None (pero la entidad se destruye)

Cada uno de estos seis escenarios se probó individualmente. Equivocarse en la semántica de cascada significaría pérdida de datos (destruir hijos que deberían eliminarse de forma suave) o fugas de datos (mantener referencias a entidades destruidas).

El conteo final de pruebas después de la Sesión 161: 2.099 pruebas pasando. El sistema de restricciones por sí solo representaba 31 de ellas -- casi tantas pruebas como líneas de código de restricciones. Cuando construyes un motor de base de datos, la proporción pruebas/código debería ser alta. Las restricciones son las barandillas de la integridad de datos, y barandillas que fallan silenciosamente son peores que no tener barandillas.

Por qué las restricciones pertenecen al modelo de datos

Existe un debate persistente en la ingeniería de software sobre dónde pertenece la validación. Algunos argumentan que pertenece a la capa de aplicación (controladores, servicios). Algunos argumentan que pertenece a la base de datos (restricciones, triggers). Algunos argumentan que pertenece al modelo de dominio (objetos de valor, invariantes).

La respuesta de FlinDB: pertenece a la definición de entidad, y la base de datos la aplica. Esto no es solo una opinión -- es una decisión arquitectónica con consecuencias concretas.

Cuando las restricciones viven en la capa de aplicación, pueden eludirse. Un desarrollador escribiendo un script de migración de datos podría omitir la validación. Una consola de administración podría escribir directamente en la base de datos. Un trabajo en segundo plano podría usar una ruta de código diferente que omita una verificación. La base de datos ve todas las escrituras, de todas las fuentes, y aplica todas las restricciones, cada vez.

Cuando las restricciones son declarativas -- escritas como anotaciones en campos de entidad -- cumplen doble función como documentación. Leer email: text @unique @pattern(...) te dice todo sobre los requisitos del campo email. No necesitas buscar en el código de aplicación, funciones de middleware o definiciones de triggers SQL para entender las reglas.

El sistema de restricciones de FlinDB no es una funcionalidad. Es una filosofía: el modelo de datos debería ser completo y auto-aplicable. Si puedes expresar una regla sobre tus datos, deberías expresarla en la definición de entidad, y la base de datos debería garantizar que se cumple.


Esta es la Parte 4 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 (estás aquí) - [060] Aggregations and Analytics

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles