package infra import ( "database/sql" "fmt" "net/http" "strconv" "strings" ) // CRUDListHandler retorna un http.HandlerFunc que lista registros de la tabla del recurso // con paginacion, orden y filtros tomados de los query params. // Query params soportados: // - page (default 1) // - per_page (default 20, max 100) // - sort_by (columna valida; default "created_at") // - sort_dir ("asc" o "desc"; default "desc") // - filter_= para WHERE exactos (solo campos definidos en el recurso) // // Si el recurso es SoftDelete, se agrega automaticamente "WHERE deleted_at IS NULL". // Retorna un CRUDListResult serializado como JSON. func CRUDListHandler(res CRUDResource, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { params := parseCRUDListParams(res, r) // Construir WHERE where := []string{} args := []any{} if res.SoftDelete { where = append(where, "deleted_at IS NULL") } for col, val := range params.Filters { where = append(where, fmt.Sprintf("%s = ?", col)) args = append(args, val) } whereSQL := "" if len(where) > 0 { whereSQL = " WHERE " + strings.Join(where, " AND ") } // COUNT total countSQL := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", res.Table, whereSQL) var total int if err := db.QueryRow(countSQL, args...).Scan(&total); err != nil { HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()}) return } // SELECT paginado offset := (params.Page - 1) * params.PerPage selectSQL := fmt.Sprintf( "SELECT * FROM %s%s ORDER BY %s %s LIMIT ? OFFSET ?", res.Table, whereSQL, params.SortBy, strings.ToUpper(params.SortDir), ) selectArgs := append(append([]any{}, args...), params.PerPage, offset) rows, err := db.Query(selectSQL, selectArgs...) if err != nil { HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()}) return } defer rows.Close() cols, err := rows.Columns() if err != nil { HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()}) return } items := []map[string]any{} for rows.Next() { row, err := crudScanRow(rows, cols) if err != nil { HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()}) return } items = append(items, row) } if err := rows.Err(); err != nil { HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()}) return } totalPages := 0 if params.PerPage > 0 { totalPages = (total + params.PerPage - 1) / params.PerPage } result := CRUDListResult{ Items: items, Total: total, Page: params.Page, PerPage: params.PerPage, TotalPages: totalPages, } HTTPJSONResponse(w, http.StatusOK, result) } } // parseCRUDListParams extrae CRUDListParams desde los query params, aplicando // defaults y validando los nombres de campo contra la definicion del recurso // para evitar SQL injection en sort_by y filter_*. func parseCRUDListParams(res CRUDResource, r *http.Request) CRUDListParams { q := r.URL.Query() page, _ := strconv.Atoi(q.Get("page")) if page < 1 { page = 1 } perPage, _ := strconv.Atoi(q.Get("per_page")) if perPage < 1 { perPage = 20 } if perPage > 100 { perPage = 100 } sortBy := q.Get("sort_by") if sortBy == "" || !isSortableColumn(res, sortBy) { sortBy = "created_at" } sortDir := strings.ToLower(q.Get("sort_dir")) if sortDir != "asc" && sortDir != "desc" { sortDir = "desc" } filters := map[string]string{} for key, vals := range q { if !strings.HasPrefix(key, "filter_") { continue } col := strings.TrimPrefix(key, "filter_") if crudFieldByName(res, col) == nil { continue // campo desconocido, se ignora (defensa SQLi) } if len(vals) > 0 { filters[col] = vals[0] } } return CRUDListParams{ Page: page, PerPage: perPage, SortBy: sortBy, SortDir: sortDir, Filters: filters, } } // isSortableColumn indica si la columna pertenece al recurso o es una columna base. func isSortableColumn(res CRUDResource, col string) bool { if col == "id" || col == "created_at" || col == "updated_at" { return true } if res.SoftDelete && col == "deleted_at" { return true } return crudFieldByName(res, col) != nil }