feat(crud): tipos y generador de DDL para recursos CRUD

Anade los tipos CRUDResource, CRUDField, CRUDListParams y CRUDListResult
que modelan un recurso CRUD sobre SQLite, junto con dos funciones puras:
- crud_define_resource valida nombre, tabla y campos (tipos SQLite validos,
  nombres reservados, duplicados) antes de retornar el CRUDResource.
- crud_generate_table_sql genera el DDL CREATE TABLE IF NOT EXISTS con
  id TEXT PRIMARY KEY, timestamps estandar y, si aplica, deleted_at para
  soft delete.

Primera capa de 0021 — el resto (handlers + registro de rutas) se apoya
sobre estas estructuras.
This commit is contained in:
2026-04-18 17:15:21 +02:00
parent 95826cb14f
commit 31708d0942
10 changed files with 562 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
package infra
import "fmt"
// CRUDDefineResource construye un CRUDResource validando que el nombre no este vacio,
// que haya al menos un campo y que todos los tipos de los campos sean validos
// (TEXT, INTEGER, REAL, BLOB). Es pura — solo valida y devuelve la estructura.
func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error) {
if name == "" {
return CRUDResource{}, fmt.Errorf("crud_define_resource: name must not be empty")
}
if table == "" {
return CRUDResource{}, fmt.Errorf("crud_define_resource: table must not be empty")
}
if len(fields) == 0 {
return CRUDResource{}, fmt.Errorf("crud_define_resource: must have at least one field")
}
seen := make(map[string]bool, len(fields))
for _, f := range fields {
if f.Name == "" {
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name must not be empty")
}
if f.Name == "id" || f.Name == "created_at" || f.Name == "updated_at" || f.Name == "deleted_at" {
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name %q is reserved", f.Name)
}
if seen[f.Name] {
return CRUDResource{}, fmt.Errorf("crud_define_resource: duplicate field name %q", f.Name)
}
seen[f.Name] = true
if !isValidCRUDType(f.Type) {
return CRUDResource{}, fmt.Errorf("crud_define_resource: invalid type %q for field %q (must be TEXT, INTEGER, REAL or BLOB)", f.Type, f.Name)
}
}
return CRUDResource{
Name: name,
Table: table,
Fields: fields,
SoftDelete: softDelete,
}, nil
}
+44
View File
@@ -0,0 +1,44 @@
---
name: crud_define_resource
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error)"
description: "Construye un CRUDResource validando nombre, tabla y campos. Rechaza nombres de campo reservados (id, created_at, updated_at, deleted_at), duplicados y tipos distintos de TEXT, INTEGER, REAL, BLOB."
tags: [crud, resource, define, validation, infra]
uses_functions: []
uses_types: [CRUDResource_go_infra, CRUDField_go_infra]
returns: [CRUDResource_go_infra]
returns_optional: false
error_type: ""
imports: [fmt]
params:
- name: name
desc: "nombre singular del recurso en snake_case (ej: 'project')"
- name: table
desc: "nombre de la tabla SQLite asociada (ej: 'projects')"
- name: fields
desc: "lista de CRUDField con los campos del recurso (sin id ni timestamps)"
- name: softDelete
desc: "si true, el recurso usa deleted_at en vez de borrado fisico"
output: "CRUDResource validado listo para pasar a crud_generate_table_sql y crud_generate_handlers"
tested: true
tests: ["construye un recurso valido", "rechaza nombre vacio", "rechaza tabla vacia", "rechaza lista de campos vacia", "rechaza tipos invalidos", "rechaza nombres reservados", "rechaza duplicados"]
test_file_path: "functions/infra/crud_test.go"
file_path: "functions/infra/crud_define_resource.go"
---
## Ejemplo
```go
res, err := CRUDDefineResource("project", "projects", []CRUDField{
{Name: "name", Type: "TEXT", Required: true, Unique: true},
{Name: "priority", Type: "INTEGER", Default: "0"},
}, false)
```
## Notas
Funcion pura — no hace I/O. Valida antes de devolver. Los campos id, created_at, updated_at y deleted_at son gestionados por el generador de tabla y los handlers, por eso estan reservados. Los tipos aceptados son los tipos de almacenamiento nativos de SQLite.
@@ -0,0 +1,39 @@
package infra
import (
"fmt"
"strings"
)
// CRUDGenerateTableSQL genera el DDL CREATE TABLE IF NOT EXISTS correspondiente a un CRUDResource.
// Incluye siempre: id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL.
// Si el recurso es SoftDelete, agrega una columna deleted_at TEXT (nullable).
// Cada CRUDField se mapea a su tipo SQLite y aplica NOT NULL, UNIQUE y DEFAULT segun corresponda.
func CRUDGenerateTableSQL(res CRUDResource) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n", res.Table))
sb.WriteString(" id TEXT PRIMARY KEY")
for _, f := range res.Fields {
sb.WriteString(",\n ")
sb.WriteString(f.Name)
sb.WriteString(" ")
sb.WriteString(strings.ToUpper(f.Type))
if f.Required {
sb.WriteString(" NOT NULL")
}
if f.Unique {
sb.WriteString(" UNIQUE")
}
if f.Default != "" {
sb.WriteString(" DEFAULT ")
sb.WriteString(f.Default)
}
}
sb.WriteString(",\n created_at TEXT NOT NULL")
sb.WriteString(",\n updated_at TEXT NOT NULL")
if res.SoftDelete {
sb.WriteString(",\n deleted_at TEXT")
}
sb.WriteString("\n);\n")
return sb.String()
}
@@ -0,0 +1,47 @@
---
name: crud_generate_table_sql
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func CRUDGenerateTableSQL(res CRUDResource) string"
description: "Genera el DDL CREATE TABLE IF NOT EXISTS de un CRUDResource. Incluye id como PRIMARY KEY, timestamps created_at/updated_at y deleted_at si soft_delete. Cada campo aplica su tipo SQLite y constraints NOT NULL/UNIQUE/DEFAULT."
tags: [crud, sql, ddl, generate, sqlite, infra]
uses_functions: []
uses_types: [CRUDResource_go_infra]
returns: []
returns_optional: false
error_type: ""
imports: [fmt, strings]
params:
- name: res
desc: "CRUDResource con la definicion completa del recurso"
output: "string con el statement CREATE TABLE listo para ejecutar"
tested: true
tests: ["genera tabla basica con timestamps", "aplica NOT NULL y UNIQUE", "aplica DEFAULT", "anade deleted_at si soft_delete"]
test_file_path: "functions/infra/crud_test.go"
file_path: "functions/infra/crud_generate_table_sql.go"
---
## Ejemplo
```go
res, _ := CRUDDefineResource("project", "projects", []CRUDField{
{Name: "name", Type: "TEXT", Required: true, Unique: true},
{Name: "priority", Type: "INTEGER", Default: "0"},
}, false)
ddl := CRUDGenerateTableSQL(res)
// CREATE TABLE IF NOT EXISTS projects (
// id TEXT PRIMARY KEY,
// name TEXT NOT NULL UNIQUE,
// priority INTEGER DEFAULT 0,
// created_at TEXT NOT NULL,
// updated_at TEXT NOT NULL
// );
db.Exec(ddl)
```
## Notas
Funcion pura — solo manipula strings. Usa CREATE TABLE IF NOT EXISTS para ser idempotente. Las columnas id, created_at y updated_at siempre se generan. Si el CRUDResource es SoftDelete, se anade deleted_at TEXT nullable. El resultado se puede ejecutar directamente con db.Exec o envolver como migracion.
+198
View File
@@ -0,0 +1,198 @@
package infra
import (
"database/sql"
"fmt"
"regexp"
"strconv"
"strings"
)
// validCRUDTypes enumera los tipos SQLite aceptados por las funciones CRUD.
var validCRUDTypes = map[string]bool{
"TEXT": true,
"INTEGER": true,
"REAL": true,
"BLOB": true,
}
// isValidCRUDType indica si el tipo string corresponde a uno soportado.
func isValidCRUDType(t string) bool {
return validCRUDTypes[strings.ToUpper(t)]
}
// crudFieldByName busca un campo por nombre. Retorna nil si no existe.
func crudFieldByName(res CRUDResource, name string) *CRUDField {
for i := range res.Fields {
if res.Fields[i].Name == name {
return &res.Fields[i]
}
}
return nil
}
// crudColumnNames retorna la lista de nombres de columnas de una tabla sqlite.
// Usa PRAGMA table_info — unica forma portable en SQLite.
func crudColumnNames(db *sql.DB, table string) ([]string, error) {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
if err != nil {
return nil, fmt.Errorf("crud column names: %w", err)
}
defer rows.Close()
var cols []string
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return nil, fmt.Errorf("crud column names: %w", err)
}
cols = append(cols, name)
}
return cols, nil
}
// crudScanRow escanea una fila generica a map[string]any usando las columnas proporcionadas.
// Usa []any con apuntadores para que database/sql decida el tipo Go.
func crudScanRow(rows *sql.Rows, cols []string) (map[string]any, error) {
values := make([]any, len(cols))
scanArgs := make([]any, len(cols))
for i := range values {
scanArgs[i] = &values[i]
}
if err := rows.Scan(scanArgs...); err != nil {
return nil, err
}
row := make(map[string]any, len(cols))
for i, col := range cols {
v := values[i]
// Normalizar bytes a string (SQLite TEXT llega como []byte cuando no se tipa)
if b, ok := v.([]byte); ok {
row[col] = string(b)
} else {
row[col] = v
}
}
return row, nil
}
// crudValidateField valida un valor contra las reglas de un campo.
// Retorna nil si todo ok, error con mensaje descriptivo si falla alguna regla.
func crudValidateField(field CRUDField, value any) error {
if value == nil {
if field.Required {
return fmt.Errorf("field %q is required", field.Name)
}
return nil
}
switch strings.ToUpper(field.Type) {
case "TEXT":
s, ok := value.(string)
if !ok {
return fmt.Errorf("field %q must be a string", field.Name)
}
return crudValidateText(field, s)
case "INTEGER":
n, err := crudCoerceInt(value)
if err != nil {
return fmt.Errorf("field %q must be an integer", field.Name)
}
return crudValidateNumber(field, float64(n))
case "REAL":
f, err := crudCoerceFloat(value)
if err != nil {
return fmt.Errorf("field %q must be a number", field.Name)
}
return crudValidateNumber(field, f)
case "BLOB":
return nil
}
return nil
}
// crudValidateText aplica min_length, max_length, pattern, enum a un string.
func crudValidateText(field CRUDField, s string) error {
if v, ok := field.Validations["min_length"]; ok {
n, err := strconv.Atoi(v)
if err == nil && len(s) < n {
return fmt.Errorf("field %q must have at least %d characters", field.Name, n)
}
}
if v, ok := field.Validations["max_length"]; ok {
n, err := strconv.Atoi(v)
if err == nil && len(s) > n {
return fmt.Errorf("field %q must have at most %d characters", field.Name, n)
}
}
if v, ok := field.Validations["pattern"]; ok {
re, err := regexp.Compile(v)
if err == nil && !re.MatchString(s) {
return fmt.Errorf("field %q does not match pattern %q", field.Name, v)
}
}
if v, ok := field.Validations["enum"]; ok {
options := strings.Split(v, ",")
matched := false
for _, opt := range options {
if strings.TrimSpace(opt) == s {
matched = true
break
}
}
if !matched {
return fmt.Errorf("field %q must be one of: %s", field.Name, v)
}
}
return nil
}
// crudValidateNumber aplica min y max a un valor numerico.
func crudValidateNumber(field CRUDField, f float64) error {
if v, ok := field.Validations["min"]; ok {
min, err := strconv.ParseFloat(v, 64)
if err == nil && f < min {
return fmt.Errorf("field %q must be >= %s", field.Name, v)
}
}
if v, ok := field.Validations["max"]; ok {
max, err := strconv.ParseFloat(v, 64)
if err == nil && f > max {
return fmt.Errorf("field %q must be <= %s", field.Name, v)
}
}
return nil
}
// crudCoerceInt intenta convertir un valor a int64.
func crudCoerceInt(v any) (int64, error) {
switch n := v.(type) {
case int:
return int64(n), nil
case int64:
return n, nil
case float64:
if n != float64(int64(n)) {
return 0, fmt.Errorf("not an integer")
}
return int64(n), nil
case string:
return strconv.ParseInt(n, 10, 64)
}
return 0, fmt.Errorf("not an integer")
}
// crudCoerceFloat intenta convertir un valor a float64.
func crudCoerceFloat(v any) (float64, error) {
switch n := v.(type) {
case int:
return float64(n), nil
case int64:
return float64(n), nil
case float64:
return n, nil
case string:
return strconv.ParseFloat(n, 64)
}
return 0, fmt.Errorf("not a number")
}
+49
View File
@@ -0,0 +1,49 @@
package infra
// CRUDResource define un recurso CRUD completo para generar handlers HTTP.
// Name es el nombre singular del recurso en snake_case (ej: "project").
// Table es el nombre de la tabla SQLite asociada (ej: "projects").
// Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
// SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
type CRUDResource struct {
Name string // nombre del recurso (singular, snake_case)
Table string // nombre de la tabla SQLite
Fields []CRUDField // campos del recurso (sin id ni timestamps)
SoftDelete bool // si true, usa deleted_at en vez de DELETE
}
// CRUDField define un campo de un recurso CRUD.
// Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
// Required fuerza NOT NULL en la tabla y validacion en create.
// Unique anade un UNIQUE constraint en la tabla.
// Default es el valor SQL por defecto (vacio = sin default).
// Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
type CRUDField struct {
Name string // nombre del campo (snake_case)
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
Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
}
// CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
// Page es 1-based (default 1). PerPage tiene default 20 y max 100.
// SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
// Filters contiene pares campo -> valor para filtros exactos en WHERE.
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
SortDir string // "asc" o "desc"
Filters map[string]string // campo -> valor para WHERE exacto
}
// CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
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"`
}
+39
View File
@@ -0,0 +1,39 @@
---
name: CRUDField
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type CRUDField struct {
Name string
Type string
Required bool
Unique bool
Default string
Validations map[string]string
}
description: "Define un campo de un recurso CRUD con su tipo SQLite, constraints y validaciones. Se agrega al slice Fields de CRUDResource."
tags: [crud, field, sqlite, validation, rest, infra]
uses_types: []
file_path: "functions/infra/crud_resource.go"
---
## Ejemplo
```go
field := CRUDField{
Name: "name",
Type: "TEXT",
Required: true,
Unique: true,
Validations: map[string]string{
"min_length": "1",
"max_length": "100",
},
}
```
## Notas
Tipo producto. Type debe ser uno de: TEXT, INTEGER, REAL, BLOB. Default es SQL literal (por ejemplo "'active'" o "0"). Validations es un mapa generico con claves: min_length, max_length, pattern (TEXT), min, max (INTEGER/REAL), enum ("a,b,c" para TEXT). Las validaciones se evaluan en Go antes de cada INSERT/UPDATE.
+35
View File
@@ -0,0 +1,35 @@
---
name: CRUDListParams
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type CRUDListParams struct {
Page int
PerPage int
SortBy string
SortDir string
Filters map[string]string
}
description: "Parametros de paginacion, orden y filtrado de un endpoint list CRUD. Se extrae de los query params de la request HTTP."
tags: [crud, list, pagination, filter, sort, infra]
uses_types: []
file_path: "functions/infra/crud_resource.go"
---
## Ejemplo
```go
params := CRUDListParams{
Page: 1,
PerPage: 20,
SortBy: "created_at",
SortDir: "desc",
Filters: map[string]string{"status": "active"},
}
```
## Notas
Tipo producto. Page es 1-based con default 1. PerPage tiene default 20 y se satura a 100. SortDir solo acepta "asc" o "desc" (default "desc"). Filters usa igualdad exacta en WHERE — los campos se validan contra la definicion del recurso para evitar SQL injection.
+35
View File
@@ -0,0 +1,35 @@
---
name: CRUDListResult
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
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"`
}
description: "Resultado paginado de un endpoint list CRUD. Incluye los items de la pagina y metadatos de paginacion para el cliente."
tags: [crud, list, pagination, result, infra]
uses_types: []
file_path: "functions/infra/crud_resource.go"
---
## Ejemplo
```go
result := CRUDListResult{
Items: []map[string]any{{"id": "a", "name": "X"}},
Total: 42,
Page: 1,
PerPage: 20,
TotalPages: 3,
}
```
## Notas
Tipo producto. Items es la lista de registros de la pagina actual serializados como map[string]any (schema dinamico). Total es el conteo global sin paginacion. TotalPages = ceil(Total / PerPage). Se serializa directamente como JSON con HTTPJSONResponse.
+36
View File
@@ -0,0 +1,36 @@
---
name: CRUDResource
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type CRUDResource struct {
Name string
Table string
Fields []CRUDField
SoftDelete bool
}
description: "Define un recurso CRUD completo (nombre, tabla, campos y modo de borrado) para generar handlers HTTP y SQL DDL. Se usa como input de crud_generate_table_sql y crud_generate_handlers."
tags: [crud, resource, http, sqlite, rest, infra]
uses_types: [CRUDField_go_infra]
file_path: "functions/infra/crud_resource.go"
---
## Ejemplo
```go
res := CRUDResource{
Name: "project",
Table: "projects",
Fields: []CRUDField{
{Name: "name", Type: "TEXT", Required: true, Unique: true},
{Name: "description", Type: "TEXT"},
},
SoftDelete: false,
}
```
## Notas
Tipo producto. Name es el singular en snake_case ("project"), Table es el plural SQL ("projects"). Fields no incluye id ni timestamps: esos los gestiona el generador. Si SoftDelete es true, la tabla tendra una columna deleted_at TEXT y el handler delete hara UPDATE en vez de DELETE.