diff --git a/functions/infra/crud_define_resource.go b/functions/infra/crud_define_resource.go new file mode 100644 index 00000000..923c6114 --- /dev/null +++ b/functions/infra/crud_define_resource.go @@ -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 +} diff --git a/functions/infra/crud_define_resource.md b/functions/infra/crud_define_resource.md new file mode 100644 index 00000000..648ad6a7 --- /dev/null +++ b/functions/infra/crud_define_resource.md @@ -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. diff --git a/functions/infra/crud_generate_table_sql.go b/functions/infra/crud_generate_table_sql.go new file mode 100644 index 00000000..b64ff4be --- /dev/null +++ b/functions/infra/crud_generate_table_sql.go @@ -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() +} diff --git a/functions/infra/crud_generate_table_sql.md b/functions/infra/crud_generate_table_sql.md new file mode 100644 index 00000000..de115e2e --- /dev/null +++ b/functions/infra/crud_generate_table_sql.md @@ -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. diff --git a/functions/infra/crud_internal.go b/functions/infra/crud_internal.go new file mode 100644 index 00000000..ef266c27 --- /dev/null +++ b/functions/infra/crud_internal.go @@ -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") +} diff --git a/functions/infra/crud_resource.go b/functions/infra/crud_resource.go new file mode 100644 index 00000000..33b3b69f --- /dev/null +++ b/functions/infra/crud_resource.go @@ -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"` +} diff --git a/types/infra/crud_field.md b/types/infra/crud_field.md new file mode 100644 index 00000000..e01981c4 --- /dev/null +++ b/types/infra/crud_field.md @@ -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. diff --git a/types/infra/crud_list_params.md b/types/infra/crud_list_params.md new file mode 100644 index 00000000..8807389c --- /dev/null +++ b/types/infra/crud_list_params.md @@ -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. diff --git a/types/infra/crud_list_result.md b/types/infra/crud_list_result.md new file mode 100644 index 00000000..e2ab9c1e --- /dev/null +++ b/types/infra/crud_list_result.md @@ -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. diff --git a/types/infra/crud_resource.md b/types/infra/crud_resource.md new file mode 100644 index 00000000..1bad62ab --- /dev/null +++ b/types/infra/crud_resource.md @@ -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.