Files

325 lines
16 KiB
Markdown

---
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.