4c88adc183
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.
199 lines
5.1 KiB
Go
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, ¬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")
|
|
}
|