Files
egutierrez 4c88adc183 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.
2026-04-18 17:15:21 +02:00

199 lines
5.1 KiB
Go

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")
}