fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
16 KiB
Markdown
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.
|