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:
@@ -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, ¬null, &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")
|
||||
}
|
||||
Reference in New Issue
Block a user