Back to flin
flin

CRUD sin SQL

Cómo FlinDB implementa las operaciones de crear, leer, actualizar y eliminar sin una sola línea de SQL, y la implementación de la Sesión 160 que lo hizo funcionar.

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

Toda aplicación jamás construida necesita cuatro operaciones: crear, leer, actualizar, eliminar. Estas operaciones son tan fundamentales que el acrónimo CRUD se ha convertido en la abreviatura de la industria para "gestión básica de datos". Sin embargo, en 2026, realizar estas cuatro operaciones aún requiere aprender SQL, configurar un ORM o encadenar llamadas a métodos en un constructor de consultas que traduce tu intención a cadenas SQL que se envían a un servidor de base de datos que las parsea de vuelta en operaciones.

FlinDB hace CRUD de manera diferente. No hay SQL. No hay ORM. No hay capa de traducción. Guardas una entidad, y se guarda. Buscas una entidad, y se encuentra. Las operaciones se mapean directamente al lenguaje, y el lenguaje se mapea directamente al motor de almacenamiento.

Esta es la historia de la Sesión 160, donde implementamos la capa CRUD completa para FlinDB -- 37 pruebas, 380 líneas de Rust, y una experiencia de desarrollador que hace que las operaciones de base de datos se sientan como asignación de variables.

Crear: guardar y listo

Crear datos en FlinDB usa la palabra clave save. No insert. No create. No db.collection.insertOne(). Simplemente save.

flin// Create and save in two steps
user = User { name: "Thales", email: "[email protected]" }
save user

// Or as a one-liner
save User { name: "Thales", email: "[email protected]" }

Después del guardado, la entidad ha sido persistida en disco a través del WAL, se le ha asignado un ID único y se le han dado campos del sistema:

flinuser.id          // 1
user.created_at  // 2026-01-13T10:00:00Z
user.version     // 1

La implementación en Rust detrás de save es donde ocurre el trabajo real. Cuando la VM de FLIN ejecuta una operación save, llama a ZeroCore:

rustpub fn save(
    &mut self,
    entity_type: &str,
    id: Option<u64>,
    fields: HashMap<String, Value>,
) -> DatabaseResult<EntityInstance> {
    let schema = self.get_schema(entity_type)?;

    // Validate all constraints
    schema.validate(&fields)?;
    self.check_unique_constraints(entity_type, id, &schema, &fields)?;

    // Determine if this is a create or update
    match id {
        None => self.create_entity(entity_type, &schema, fields),
        Some(id) => self.update_entity(entity_type, id, fields),
    }
}

El método save maneja tanto la creación como las actualizaciones. Si la entidad no tiene ID, es una entidad nueva -- ZeroCore genera un ID, crea la versión inicial y la inserta en el almacenamiento. Si la entidad ya tiene un ID, es una actualización -- ZeroCore crea una nueva versión, preservando la antigua en el historial.

Este doble comportamiento es la razón por la que FLIN usa save en lugar de comandos separados create y update. Desde la perspectiva del desarrollador, haces cambios en una entidad y la guardas. Si es nueva o existente es un detalle de implementación.

Leer: find, all, where, first

Leer datos en FlinDB ofrece cuatro patrones, cada uno diseñado para un caso de uso específico.

Find por ID es el más directo. Conoces el ID, obtienes la entidad:

flinuser = User.find(1)

Bajo la superficie, esto es una búsqueda O(1) en el HashMap de datos de ZeroCore:

rustpub fn find_by_id(
    &self,
    entity_type: &str,
    id: u64,
) -> DatabaseResult<EntityInstance> {
    let collection = self.data.get(entity_type)
        .ok_or(DatabaseError::EntityTypeNotFound)?;

    let versions = collection.get(&id)
        .ok_or(DatabaseError::NotFound(id))?;

    // Return the latest non-deleted version
    versions.last()
        .filter(|v| v.deleted_at.is_none())
        .cloned()
        .ok_or(DatabaseError::NotFound(id))
}

All devuelve todas las entidades de un tipo:

flinusers = User.all

Where filtra entidades por condiciones:

flin// Simple condition
active_users = User.where(active == true)

// Multiple conditions
admins = User.where(role == "admin" && active == true)

// Comparison operators
expensive = Product.where(price > 1000)
recent = Post.where(created_at > yesterday)

First devuelve la primera entidad que coincide con una condición:

flinadmin = User.first(role == "admin")

Count devuelve el número de entidades, opcionalmente filtradas:

flintotal = User.count
active = User.count(active == true)

El método count_where en ZeroCore usa un enfoque basado en predicados que evita materializar el conjunto completo de resultados:

rustpub fn count_where<F>(
    &self,
    entity_type: &str,
    predicate: F,
) -> DatabaseResult<usize>
where
    F: Fn(&EntityInstance) -> bool,
{
    let collection = self.data.get(entity_type)
        .ok_or(DatabaseError::EntityTypeNotFound)?;

    Ok(collection.values()
        .filter_map(|versions| versions.last())
        .filter(|v| v.deleted_at.is_none())
        .filter(|v| predicate(v))
        .count())
}

Encadenamiento de consultas

Las consultas de FlinDB son encadenables. Puedes combinar filtrado, ordenamiento y limitación en una API fluida:

flinresults = Product
    .where(category == "electronics")
    .where(price < 1000)
    .order(rating, desc)
    .limit(10)

Esto se lee como lenguaje natural: "Dame productos en la categoría electrónica, con precio menor a 1000, ordenados por calificación descendente, limitados a 10 resultados."

El constructor de consultas en Rust acumula condiciones y las ejecuta en una sola pasada:

rustdb.query("Product")
    .where_eq("category", Value::Text("electronics".into()))
    .where_lt("price", Value::Int(1000))
    .order_by_desc("rating")
    .limit(10)
    .execute()?;

El ordenamiento soporta múltiples campos:

flinsorted = Product.all.order(category, asc).order(price, desc)

La paginación está integrada:

flinpage2 = User.all.limit(20).offset(20)

Estas operaciones se componen naturalmente porque el constructor de consultas se devuelve a sí mismo después de cada llamada a método, permitiendo encadenamiento indefinido hasta que se llama a execute().

Actualizar: modificar y guardar

Actualizar una entidad en FlinDB es idéntico a crear una -- modificas campos y llamas a save:

flinuser = User.find(1)
user.name = "New Name"
save user

Esta simplicidad oculta comportamiento importante. Cuando se llama a save en una entidad existente:

  1. El estado actual se convierte en una versión histórica
  2. El nuevo estado se convierte en la versión actual
  3. El número de versión se incrementa
  4. La marca temporal updated_at se establece al momento actual
  5. El WAL recibe una nueva entrada

El desarrollador nunca piensa en la gestión de versiones. Cambia un campo y guarda. El modelo temporal se encarga de todo lo demás.

Eliminar: suave por defecto

La operación de eliminación de FlinDB es suave por defecto:

flinuser = User.find(1)
delete user

La eliminación suave significa que la entidad se marca con una marca temporal deleted_at pero permanece en el almacenamiento. Desaparece de las consultas -- User.all no la devolverá, User.count no la contará, User.where(...) la omitirá. Pero puede recuperarse:

flin// The entity still exists in the database
// It just does not appear in queries

Todas las consultas en ZeroCore filtran automáticamente las entidades eliminadas de forma suave:

rust// In every query method:
.filter(|v| v.deleted_at.is_none())

Este filtro se aplica a nivel del motor, no a nivel de consulta. No hay forma de que un desarrollador incluya accidentalmente entidades eliminadas en un resultado de consulta. El motor garantiza que las entidades eliminadas son invisibles a menos que se accedan explícitamente a través de su ID.

Para la eliminación permanente, FLIN proporciona destroy:

flinuser = User.find(1)
destroy user

destroy elimina permanentemente la entidad y todo su historial de versiones. Esto es irreversible y está destinado para escenarios de cumplimiento (derecho al olvido del GDPR) o recuperación de almacenamiento.

La suite de pruebas de integración

La Sesión 160 no se trató solo de implementar CRUD. Se trató de demostrar que la implementación era correcta. Escribimos 14 pruebas de integración completas que ejercitan cada aspecto de la capa CRUD:

rust// DB9-01: Entity creation
#[test]
fn test_flindb_entity_creation() { /* ... */ }

// DB9-02: Save assigns ID
#[test]
fn test_flindb_save_assigns_id() { /* ... */ }

// DB9-03: Find by ID
#[test]
fn test_flindb_find_by_id() { /* ... */ }

// DB9-04: Delete (soft delete)
#[test]
fn test_flindb_delete_soft() { /* ... */ }

// DB9-05: Where clauses
#[test]
fn test_flindb_where_equality() { /* ... */ }

// DB9-06: Query ordering
#[test]
fn test_flindb_order_by() { /* ... */ }

// DB9-07: Pagination
#[test]
fn test_flindb_pagination() { /* ... */ }

// DB9-08: Count operation
#[test]
fn test_flindb_count() { /* ... */ }

// DB9-09: Chained queries
#[test]
fn test_flindb_chained_query() { /* ... */ }

// DB9-10: History property
#[test]
fn test_flindb_history_property() { /* ... */ }

Las últimas cuatro pruebas fueron particularmente importantes. Probaron escenarios del mundo real: una aplicación de blog (entidades User + Post), una aplicación de comercio electrónico (entidades Product + Order), una aplicación de tareas (filtrar por estado de completitud) y consultas combinadas first/all. No eran pruebas unitarias de métodos individuales -- eran pruebas de extremo a extremo de todo el pipeline CRUD, desde la creación de entidades hasta la consulta y eliminación.

Las pruebas de la operación de eliminación fueron especialmente exhaustivas:

  • test_delete_preserves_all_history -- verifica que la eliminación suave mantiene el historial de versiones intacto
  • test_delete_vs_destroy_history_difference -- verifica la diferencia de comportamiento entre delete y destroy
  • test_delete_find_excludes_deleted -- verifica que find no devuelve entidades eliminadas de forma suave
  • test_delete_all_excludes_deleted -- verifica que all excluye entidades eliminadas de forma suave
  • test_delete_query_excludes_deleted -- verifica que las consultas where excluyen entidades eliminadas de forma suave
  • test_restore_after_delete -- verifica que las entidades eliminadas de forma suave pueden restaurarse

Seis pruebas para una operación. Este nivel de cobertura fue necesario porque la semántica de eliminación es donde la mayoría de las abstracciones de base de datos fallan. La frontera entre "eliminado" y "no eliminado" afecta cada ruta de consulta, y un bug aquí sería invisible hasta que los datos de producción empezaran a desaparecer de los resultados o, peor aún, a aparecer cuando no deberían.

La brecha de experiencia del desarrollador

Para entender por qué el CRUD sin SQL importa, considera la diferencia en carga cognitiva.

En una aplicación típica de Node.js con Prisma:

javascript// Create
const user = await prisma.user.create({
  data: {
    name: "Thales",
    email: "[email protected]",
  },
});

// Read
const users = await prisma.user.findMany({
  where: {
    active: true,
  },
  orderBy: {
    createdAt: 'desc',
  },
  take: 10,
});

// Update
await prisma.user.update({
  where: { id: 1 },
  data: { name: "New Name" },
});

// Delete
await prisma.user.delete({
  where: { id: 1 },
});

Esta ya es una de las mejores experiencias de desarrollador en el ecosistema SQL. Pero cuenta los conceptos: cliente prisma, métodos create/findMany/update/delete, parámetros data/where/orderBy/take, await para async, objetos anidados para condiciones. Un desarrollador debe entender todo esto antes de escribir su primera operación CRUD.

En FLIN:

flin// Create
user = User { name: "Thales", email: "[email protected]" }
save user

// Read
users = User.where(active == true).order(created_at, desc).limit(10)

// Update
user = User.find(1)
user.name = "New Name"
save user

// Delete
delete User.find(1)

La versión FLIN se lee como pseudocódigo. No hay cliente que importar. No hay async/await que gestionar. No hay objetos de configuración anidados. Las operaciones son verbos en el lenguaje, no métodos en una biblioteca. Un desarrollador que nunca ha visto FLIN antes puede leer este código y entender lo que hace.

Ese es el punto. El CRUD no es la parte interesante del desarrollo de aplicaciones. Es la fontanería. FlinDB hace que la fontanería desaparezca para que los desarrolladores puedan enfocarse en las partes de su aplicación que realmente importan.


Esta es la Parte 3 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 (estás aquí) - [059] Constraints and Validation in FlinDB - [060] Aggregations and Analytics

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles