---
id: "0021"
title: "CRUD Generator"
status: completado
type: feature
domain: []
scope: multi-app
priority: media
depends: []
blocks: []
related: []
created: 2026-05-17
updated: 2026-05-17
tags: []
---
# 0021 — CRUD Generator

## Metadata

| Campo | Valor |
|-------|-------|
| **ID** | 0021 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |

## Dependencias

- **0009** (HTTP Server Foundation) — los handlers generados usan `http_json_response`, `http_error_response`, `http_parse_body` y se registran via `http_router`.
- **0015** (DB Migrations) — la tabla generada por `crud_generate_table_sql` se ejecuta como migracion.

---

## Objetivo

Generar handlers REST completos (list, get, create, update, delete) a partir de una definicion declarativa de recurso, sin escribir codigo repetitivo. El 80% de los endpoints de cualquier API son CRUD identico — solo cambian el nombre de la tabla y los campos.

## Contexto

- Ya existen `wails_bind_crud_go_infra` (genera bindings CRUD para Wails como string de codigo Go) y `crud_page_ts_ui` (layout CRUD en frontend). Pero no hay nada que genere **endpoints HTTP** CRUD funcionales.
- Las apps del registry (`sqlite_api`, `deploy_server`, futuras) construyen sus handlers CRUD a mano cada vez: misma estructura de list con paginacion, mismo get por ID, mismo create con validacion, mismo update parcial, mismo delete.
- Este issue NO genera archivos ni templates — genera **handler factories en runtime**. Le pasas una definicion de recurso y te devuelve `http.HandlerFunc` listas para usar.
- Especifico para SQLite (usa `database/sql` con mattn/go-sqlite3). No abstrae multiples motores de BD.

## Arquitectura

```
functions/infra/
  crud_define_resource.go          — NEW: construye CRUDResource a partir de nombre y campos
  crud_define_resource.md          — NEW
  crud_generate_table_sql.go       — NEW: genera CREATE TABLE a partir de CRUDResource
  crud_generate_table_sql.md       — NEW
  crud_generate_handlers.go        — NEW: genera los 5 handlers a partir de CRUDResource + *sql.DB
  crud_generate_handlers.md        — NEW
  crud_register_routes.go          — NEW: registra rutas CRUD en router
  crud_register_routes.md          — NEW
  crud_list_handler.go             — NEW: handler generico list con paginacion/filtro/sort
  crud_list_handler.md             — NEW
  crud_get_handler.go              — NEW: handler get por ID
  crud_get_handler.md              — NEW
  crud_create_handler.go           — NEW: handler create con validacion
  crud_create_handler.md           — NEW
  crud_update_handler.go           — NEW: handler update parcial por ID
  crud_update_handler.md           — NEW
  crud_delete_handler.go           — NEW: handler delete por ID
  crud_delete_handler.md           — NEW

types/infra/
  crud_resource.md                 — NEW: metadata del tipo CRUDResource
  crud_field.md                    — NEW: metadata del tipo CRUDField
  crud_list_params.md              — NEW: metadata del tipo CRUDListParams
  crud_list_result.md              — NEW: metadata del tipo CRUDListResult
```

### Patron pure core / impure shell

- **Pure:** `crud_define_resource` (construye struct), `crud_generate_table_sql` (string SQL), `crud_generate_handlers` (retorna handlers sin ejecutar I/O, pero los handlers en si hacen I/O al invocarse — la funcion factory es pure, el handler resultante es impure)
- **Impure:** `crud_register_routes` (muta router), `crud_list_handler`, `crud_get_handler`, `crud_create_handler`, `crud_update_handler`, `crud_delete_handler` (todos hacen I/O contra SQLite y HTTP)

**Nota sobre `crud_generate_handlers`:** La funcion que genera los handlers es pura (recibe definicion + db, retorna funciones). Pero los handlers retornados son closures impuros que leen/escriben BD y HTTP. Se clasifica como **pure** porque la funcion en si no hace I/O — solo construye closures.

## Diseno

### Tipos

```go
// CRUDResource define un recurso CRUD completo
type CRUDResource struct {
    Name       string      // nombre del recurso (singular, snake_case: "project")
    Table      string      // nombre de la tabla SQLite ("projects")
    Fields     []CRUDField // campos del recurso (sin ID ni timestamps)
    SoftDelete bool        // si true, usa deleted_at en vez de DELETE real
}

// CRUDField define un campo del recurso
type CRUDField struct {
    Name        string            // nombre del campo (snake_case: "display_name")
    Type        string            // tipo SQLite: TEXT, INTEGER, REAL, BLOB
    Required    bool              // NOT NULL + validacion en create
    Unique      bool              // UNIQUE constraint
    Default     string            // valor por defecto en CREATE TABLE (vacio = sin default)
    Validations map[string]string // reglas: "min_length":"3", "max_length":"255", "pattern":"^[a-z]+"
}

// CRUDListParams parametros de paginacion, orden y filtro
type CRUDListParams struct {
    Page    int               // pagina actual (1-based, default 1)
    PerPage int               // items por pagina (default 20, max 100)
    SortBy  string            // campo por el que ordenar (default "created_at")
    SortDir string            // "asc" o "desc" (default "desc")
    Filters map[string]string // campo -> valor para WHERE exacto
}

// CRUDListResult resultado paginado
type CRUDListResult struct {
    Items      []map[string]any `json:"items"`
    Total      int              `json:"total"`
    Page       int              `json:"page"`
    PerPage    int              `json:"per_page"`
    TotalPages int              `json:"total_pages"`
}
```

### Funciones

| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `crud_define_resource` | pure | `(name string, table string, fields []CRUDField, softDelete bool) CRUDResource` |
| `crud_generate_table_sql` | pure | `(res CRUDResource) string` |
| `crud_generate_handlers` | pure | `(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc` |
| `crud_register_routes` | impure | `(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)` |
| `crud_list_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_get_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_create_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_update_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_delete_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |

### Comportamiento de cada handler

**List** (`GET /basePath`):
- Query params: `page`, `per_page`, `sort_by`, `sort_dir`, `filter_{campo}={valor}`
- Genera `SELECT * FROM table WHERE ... ORDER BY ... LIMIT ... OFFSET ...`
- Cuenta total con `SELECT COUNT(*) FROM table WHERE ...`
- Retorna `CRUDListResult` como JSON
- Si `soft_delete`, agrega `WHERE deleted_at IS NULL` automaticamente

**Get** (`GET /basePath/{id}`):
- Genera `SELECT * FROM table WHERE id = ?`
- 404 si no existe (o si `soft_delete` y tiene `deleted_at`)
- Retorna el registro como JSON

**Create** (`POST /basePath`):
- Parsea body JSON con `http_parse_body`
- Valida campos required y validaciones de cada campo
- Genera UUID para `id`, timestamp para `created_at` y `updated_at`
- `INSERT INTO table (id, field1, ..., created_at, updated_at) VALUES (?, ?, ..., ?, ?)`
- Retorna 201 con el registro creado

**Update** (`PUT /basePath/{id}`):
- Parsea body JSON — solo campos presentes se actualizan (partial update)
- Valida campos enviados contra sus reglas
- `UPDATE table SET field1=?, updated_at=? WHERE id=?`
- 404 si no existe
- Retorna el registro actualizado

**Delete** (`DELETE /basePath/{id}`):
- Si `soft_delete`: `UPDATE table SET deleted_at=? WHERE id=?`
- Si no: `DELETE FROM table WHERE id=?`
- 404 si no existe
- Retorna 204 sin body

### SQL generado por `crud_generate_table_sql`

```sql
-- Para un recurso "projects" con campos name (TEXT, required, unique) y description (TEXT)
CREATE TABLE IF NOT EXISTS projects (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    description TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

-- Si soft_delete:
CREATE TABLE IF NOT EXISTS projects (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    description TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted_at TEXT
);
```

### Validaciones soportadas

| Clave | Aplica a | Ejemplo |
|-------|----------|---------|
| `min_length` | TEXT | `"3"` — minimo 3 caracteres |
| `max_length` | TEXT | `"255"` — maximo 255 caracteres |
| `pattern` | TEXT | `"^[a-z_]+$"` — regex match |
| `min` | INTEGER, REAL | `"0"` — valor minimo |
| `max` | INTEGER, REAL | `"1000"` — valor maximo |
| `enum` | TEXT | `"active,inactive,archived"` — valores permitidos |

---

## Tareas

### Fase 1: Tipos y definicion

- [ ] **1.1** Crear tipos `CRUDResource`, `CRUDField`, `CRUDListParams`, `CRUDListResult` en `functions/infra/` con `.md` en `types/infra/`
- [ ] **1.2** `crud_define_resource` — construye `CRUDResource` validando que haya al menos un campo y que los tipos sean validos (TEXT, INTEGER, REAL, BLOB)
- [ ] **1.3** `crud_generate_table_sql` — genera DDL `CREATE TABLE IF NOT EXISTS` con las constraints derivadas de los campos

### Fase 2: Handlers individuales

- [ ] **2.1** `crud_list_handler` — parsea query params a `CRUDListParams`, construye SQL dinamico con paginacion y filtros, retorna `CRUDListResult`
- [ ] **2.2** `crud_get_handler` — busca por ID, escanea columnas dinamicamente a `map[string]any`, responde 404 si no existe
- [ ] **2.3** `crud_create_handler` — valida input contra definicion, genera UUID, inserta, retorna 201
- [ ] **2.4** `crud_update_handler` — partial update con solo los campos enviados, valida los presentes, 404 si no existe
- [ ] **2.5** `crud_delete_handler` — hard/soft delete segun config, 404 si no existe, retorna 204

### Fase 3: Composicion y tests

- [ ] **3.1** `crud_generate_handlers` — llama a los 5 handlers individuales, retorna `map[string]http.HandlerFunc` con keys "list", "get", "create", "update", "delete"
- [ ] **3.2** `crud_register_routes` — registra en `http.ServeMux` las rutas `GET /base`, `GET /base/{id}`, `POST /base`, `PUT /base/{id}`, `DELETE /base/{id}`
- [ ] **3.3** Tests con `httptest.NewServer` + SQLite in-memory (`:memory:`) para cada handler
- [ ] **3.4** Test de integracion: define recurso, genera tabla, registra rutas, CRUD completo via HTTP
- [ ] **3.5** `fn index` y verificar con `fn show` que todas las funciones y tipos aparecen

---

## Ejemplo de uso

```go
// 1. Definir el recurso
resource := infra.CRUDDefineResource("project", "projects", []infra.CRUDField{
    {Name: "name", Type: "TEXT", Required: true, Unique: true,
        Validations: map[string]string{"min_length": "1", "max_length": "100"}},
    {Name: "description", Type: "TEXT"},
    {Name: "status", Type: "TEXT", Required: true, Default: "'active'",
        Validations: map[string]string{"enum": "active,archived,deleted"}},
    {Name: "priority", Type: "INTEGER", Default: "0",
        Validations: map[string]string{"min": "0", "max": "10"}},
}, false) // soft_delete = false

// 2. Crear la tabla
ddl := infra.CRUDGenerateTableSQL(resource)
db.Exec(ddl)

// 3. Registrar rutas (una linea)
mux := http.NewServeMux()
infra.CRUDRegisterRoutes(mux, "/api/projects", resource, db)

// Listo. Endpoints disponibles:
// GET    /api/projects              — list con paginacion
// GET    /api/projects/{id}         — get por ID
// POST   /api/projects              — create
// PUT    /api/projects/{id}         — update parcial
// DELETE /api/projects/{id}         — delete

// 4. Multiples recursos en la misma API
infra.CRUDRegisterRoutes(mux, "/api/users", userResource, db)
infra.CRUDRegisterRoutes(mux, "/api/tasks", taskResource, db)

// 5. Componer con middlewares de 0009
middleware := infra.HttpMiddlewareChain(
    infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST", "PUT", "DELETE"}),
    infra.HttpLoggerMiddleware(os.Stdout),
)

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

infra.HttpServe(":8080", middleware(mux), ctx)
```

```bash
# Uso desde curl:

# List con paginacion y filtros
curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active"

# Get
curl "localhost:8080/api/projects/abc-123"

# Create
curl -X POST localhost:8080/api/projects \
  -H 'Content-Type: application/json' \
  -d '{"name":"mi-proyecto","description":"Desc","status":"active","priority":5}'

# Update parcial (solo cambia description)
curl -X PUT localhost:8080/api/projects/abc-123 \
  -H 'Content-Type: application/json' \
  -d '{"description":"Nueva descripcion"}'

# Delete
curl -X DELETE localhost:8080/api/projects/abc-123
```

## Decisiones de diseno

- **Runtime handler factories, no code generation:** `wails_bind_crud_go_infra` genera codigo como string para Wails. Este issue toma el camino opuesto: las funciones **son** los handlers, no generan texto. Esto evita templates, archivos generados y el ciclo generate-compile. El tradeoff es que los handlers son genericos (`map[string]any`) en vez de tipados, pero para APIs REST sobre SQLite es aceptable.
- **`map[string]any` en vez de structs tipados:** Como los campos se definen en runtime, no es posible usar structs Go tipados. Toda la serializacion pasa por `map[string]any`. Esto es idiomatico para APIs JSON + SQLite donde el schema es dinamico.
- **SQLite especifico, no multi-motor:** Simplifica enormemente el SQL generado (TEXT para timestamps, sin SERIAL, sin esquemas). Si en el futuro se necesita Postgres, se crea una variante separada.
- **Validacion en Go, no en SQLite:** Las constraints de SQLite (CHECK, NOT NULL) son la ultima linea de defensa. La validacion principal ocurre en el handler antes del INSERT/UPDATE, con mensajes de error descriptivos para el cliente.
- **UUID como ID:** Todos los recursos usan `id TEXT PRIMARY KEY` con UUID generado server-side. No IDs autoincrement — evita problemas de concurrencia y es mas portable.
- **Timestamps como TEXT ISO 8601:** Consistente con como registry.db y operations.db ya almacenan timestamps.
- **Partial update en PUT:** Normalmente PATCH es para partial update y PUT para replace completo. Aqui se usa PUT con partial update por simplicidad — solo los campos presentes en el JSON se actualizan. Es pragmatico para APIs internas.

## Relacion con funciones existentes

| Funcion existente | Relacion |
|---|---|
| `wails_bind_crud_go_infra` | Genera **codigo Go** como string para desktop (Wails). Este issue genera **handlers HTTP** en runtime para REST APIs. Complementarios, no solapados. |
| `crud_page_ts_ui` | Frontend CRUD layout en React. Los handlers generados aqui serian el **backend** que esa pagina consume. Stack completo: `crud_define_resource` (definicion) + handlers (backend) + `crud_page` (frontend). |

## Riesgos

- **SQL injection en filtros:** Los filtros se pasan como query params y se inyectan en WHERE. Mitigado validando que el campo exista en la definicion del recurso y usando siempre `?` placeholders — nunca interpolacion de strings en SQL.
- **Performance en tablas grandes:** El `SELECT COUNT(*)` para paginacion se ejecuta en cada list request. Para tablas con millones de filas esto es lento. Mitigado: las apps del registry manejan miles de registros, no millones. Si se necesita, se anade cache de count como mejora futura.
- **Scope creep hacia un ORM:** Hay tentacion de agregar relaciones, joins, nested resources, hooks before/after. Mitigado limitando el scope a CRUD plano de una tabla. Relaciones y logica de negocio van en handlers custom, no en el generador.
- **Colision con 0009 si cambia la API:** Los handlers usan `http_json_response` y `http_error_response` de 0009. Si esas firmas cambian, hay que actualizar. Mitigado: las funciones de 0009 son primitivas estables con firmas simples.
