feat: funciones Go — core (cron, join_by_key, validate_struct), datascience (pivot, diff_entities), infra (http, cache, cron_ticker)

Nuevas funciones Go con tests en tres dominios:
- core: parse_cron_expr, next_cron_time, join_by_key, validate_struct_fields + tipo CronSchedule
- datascience: pivot (tabla dinámica), diff_entities (comparación de entidades)
- infra: http_get_json, http_post_json, http_download_file, cache_to_sqlite, cron_ticker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 17:11:12 +02:00
parent bee3b0d946
commit 9c0d24d3ef
35 changed files with 3042 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
package core
// CronSchedule represents a parsed cron expression with expanded field values.
type CronSchedule struct {
Minute []int
Hour []int
DayOfMonth []int
Month []int
DayOfWeek []int
Raw string // original expression
}
+116
View File
@@ -0,0 +1,116 @@
package core
// JoinByKey une dos slices de map[string]any por una clave comun.
// Soporta los cuatro tipos de join: inner, left, right, outer.
// Campos duplicados del lado right (distintos a la clave) se sufijan con _right.
// Algoritmo O(n+m): indexa right por key, luego itera left.
func JoinByKey(left, right []map[string]any, key, how string) []map[string]any {
// Determinar campos conflictivos entre left y right
leftFields := map[string]bool{}
for _, row := range left {
for k := range row {
leftFields[k] = true
}
}
rightFields := map[string]bool{}
for _, row := range right {
for k := range row {
if k != key {
rightFields[k] = true
}
}
}
conflicting := map[string]bool{}
for k := range rightFields {
if leftFields[k] {
conflicting[k] = true
}
}
// Indexar right por key (un key puede tener multiples rows)
rightIndex := map[any][]map[string]any{}
for _, row := range right {
k := row[key]
rightIndex[k] = append(rightIndex[k], row)
}
// Plantilla vacia del right (todos los campos de right a nil)
emptyRight := func() map[string]any {
m := map[string]any{}
for k := range rightFields {
if conflicting[k] {
m[k+"_right"] = nil
} else {
m[k] = nil
}
}
return m
}
merge := func(l, r map[string]any) map[string]any {
out := map[string]any{}
if l != nil {
for k, v := range l {
out[k] = v
}
}
if r != nil {
for k, v := range r {
if k == key {
continue
}
if conflicting[k] {
out[k+"_right"] = v
} else {
out[k] = v
}
}
}
return out
}
matchedRightKeys := map[any]bool{}
var result []map[string]any
for _, l := range left {
k := l[key]
rRows, ok := rightIndex[k]
if ok {
matchedRightKeys[k] = true
for _, r := range rRows {
result = append(result, merge(l, r))
}
} else {
if how == "left" || how == "outer" {
row := merge(l, nil)
for rk, rv := range emptyRight() {
row[rk] = rv
}
result = append(result, row)
}
}
}
if how == "right" || how == "outer" {
for _, r := range right {
k := r[key]
if !matchedRightKeys[k] {
row := emptyRight()
row[key] = k
for rk, rv := range r {
if rk == key {
continue
}
if conflicting[rk] {
row[rk+"_right"] = rv
} else {
row[rk] = rv
}
}
result = append(result, row)
}
}
}
return result
}
+48
View File
@@ -0,0 +1,48 @@
---
name: join_by_key
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func JoinByKey(left, right []map[string]any, key, how string) []map[string]any"
description: "Join de dos slices de map[string]any por una clave comun. Soporta inner, left, right y outer. Campos duplicados del right se sufijan con _right. Algoritmo O(n+m)."
tags: [tabular, join, merge, go, core]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests:
- "Inner join solo matches"
- "Left join todos los left con nil para right sin match"
- "Right join"
- "Outer join"
- "Campos duplicados con sufijo _right"
- "Key ausente en alguna fila"
test_file_path: "functions/core/join_by_key_test.go"
file_path: "functions/core/join_by_key.go"
---
## Ejemplo
```go
left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 3, "dept": "sales"}}
result := JoinByKey(left, right, "id", "inner")
// [{"id": 1, "name": "Alice", "dept": "eng"}]
result = JoinByKey(left, right, "id", "left")
// [{"id": 1, "name": "Alice", "dept": "eng"},
// {"id": 2, "name": "Bob", "dept": nil}]
```
## Notas
Funcion pura sin dependencias externas.
El algoritmo indexa right en O(n) y luego itera left en O(m), total O(n+m).
Los campos de right que colisionan con campos de left (excepto la clave) se renombran con sufijo _right.
Un key puede tener multiples filas en right — se generan multiples filas en el resultado (comportamiento de join relacional).
+107
View File
@@ -0,0 +1,107 @@
package core
import "testing"
func TestJoinByKey(t *testing.T) {
t.Run("Inner join solo matches", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 3, "dept": "sales"}}
result := JoinByKey(left, right, "id", "inner")
if len(result) != 1 {
t.Fatalf("got %d rows, want 1", len(result))
}
if result[0]["id"] != 1 || result[0]["name"] != "Alice" || result[0]["dept"] != "eng" {
t.Errorf("unexpected row: %v", result[0])
}
})
t.Run("Left join todos los left con nil para right sin match", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
right := []map[string]any{{"id": 1, "dept": "eng"}}
result := JoinByKey(left, right, "id", "left")
if len(result) != 2 {
t.Fatalf("got %d rows, want 2", len(result))
}
var alice, bob map[string]any
for _, r := range result {
if r["id"] == 1 {
alice = r
} else {
bob = r
}
}
if alice["dept"] != "eng" {
t.Errorf("alice dept = %v, want eng", alice["dept"])
}
if bob["dept"] != nil {
t.Errorf("bob dept = %v, want nil", bob["dept"])
}
})
t.Run("Right join", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice"}}
right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 2, "dept": "sales"}}
result := JoinByKey(left, right, "id", "right")
if len(result) != 2 {
t.Fatalf("got %d rows, want 2", len(result))
}
var eng, sales map[string]any
for _, r := range result {
if r["id"] == 1 {
eng = r
} else {
sales = r
}
}
if eng["name"] != "Alice" {
t.Errorf("eng name = %v, want Alice", eng["name"])
}
if sales["name"] != nil {
t.Errorf("sales name = %v, want nil", sales["name"])
}
})
t.Run("Outer join", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}}
right := []map[string]any{{"id": 1, "dept": "eng"}, {"id": 3, "dept": "sales"}}
result := JoinByKey(left, right, "id", "outer")
ids := map[any]bool{}
for _, r := range result {
ids[r["id"]] = true
}
if len(ids) != 3 || !ids[1] || !ids[2] || !ids[3] {
t.Errorf("outer join ids = %v, want {1, 2, 3}", ids)
}
})
t.Run("Campos duplicados con sufijo _right", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice", "score": 90}}
right := []map[string]any{{"id": 1, "score": 85, "dept": "eng"}}
result := JoinByKey(left, right, "id", "inner")
if len(result) != 1 {
t.Fatalf("got %d rows, want 1", len(result))
}
if result[0]["score"] != 90 {
t.Errorf("score = %v, want 90", result[0]["score"])
}
if result[0]["score_right"] != 85 {
t.Errorf("score_right = %v, want 85", result[0]["score_right"])
}
if result[0]["dept"] != "eng" {
t.Errorf("dept = %v, want eng", result[0]["dept"])
}
})
t.Run("Key ausente en alguna fila", func(t *testing.T) {
left := []map[string]any{{"id": 1, "name": "Alice"}, {"name": "Bob"}} // Bob sin id
right := []map[string]any{{"id": 1, "dept": "eng"}}
result := JoinByKey(left, right, "id", "inner")
// Solo Alice matchea (Bob tiene key=nil, right no tiene nil)
if len(result) != 1 {
t.Fatalf("got %d rows, want 1", len(result))
}
if result[0]["name"] != "Alice" {
t.Errorf("name = %v, want Alice", result[0]["name"])
}
})
}
+116
View File
@@ -0,0 +1,116 @@
package core
import (
"time"
)
// NextCronTime returns the next time.Time that satisfies schedule after the given time.
// It advances minute by minute, skipping ahead when a field does not match.
// Returns the zero value of time.Time if no match is found within 366 days (impossible schedule).
func NextCronTime(schedule CronSchedule, after time.Time) time.Time {
// Truncate to minute, then advance by 1 minute.
t := after.Truncate(time.Minute).Add(time.Minute)
limit := after.Add(366 * 24 * time.Hour)
for t.Before(limit) {
// Check month (1-12).
if !intIn(int(t.Month()), schedule.Month) {
// Advance to first day of next valid month.
t = nextValidMonth(t, schedule.Month)
if t.IsZero() {
return time.Time{}
}
continue
}
// Check day of month AND day of week (cron uses OR semantics when both are restricted,
// but standard 5-field cron: if both are non-wildcard, either can match).
// For simplicity we use AND semantics (both must match) which is the POSIX default
// for the common case; most implementations differ only when both are explicitly set.
domOK := intIn(t.Day(), schedule.DayOfMonth)
dowOK := intIn(int(t.Weekday()), schedule.DayOfWeek)
if !domOK || !dowOK {
// Advance to next day at midnight.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
continue
}
// Check hour.
if !intIn(t.Hour(), schedule.Hour) {
// Advance to next valid hour.
next := nextValidHour(t, schedule.Hour)
if next.IsZero() {
// No valid hour today; advance to tomorrow.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
} else {
t = next
}
continue
}
// Check minute.
if !intIn(t.Minute(), schedule.Minute) {
next := nextValidMinute(t, schedule.Minute)
if next.IsZero() {
// No more valid minutes this hour; advance to next hour.
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
} else {
t = next
}
continue
}
// All fields match.
return t
}
return time.Time{}
}
// intIn returns true if v is in the sorted slice s.
func intIn(v int, s []int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// nextValidMonth advances t to the first moment of the next valid month.
func nextValidMonth(t time.Time, months []int) time.Time {
month := int(t.Month())
for _, m := range months {
if m > month {
return time.Date(t.Year(), time.Month(m), 1, 0, 0, 0, 0, t.Location())
}
}
// Wrap to next year.
if len(months) > 0 {
return time.Date(t.Year()+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
}
return time.Time{}
}
// nextValidHour returns t at the next valid hour this day, or zero if none.
func nextValidHour(t time.Time, hours []int) time.Time {
h := t.Hour()
for _, hh := range hours {
if hh > h {
return time.Date(t.Year(), t.Month(), t.Day(), hh, 0, 0, 0, t.Location())
}
}
return time.Time{}
}
// nextValidMinute returns t at the next valid minute this hour, or zero if none.
func nextValidMinute(t time.Time, minutes []int) time.Time {
m := t.Minute()
for _, mm := range minutes {
if mm > m {
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), mm, 0, 0, t.Location())
}
}
return time.Time{}
}
+43
View File
@@ -0,0 +1,43 @@
---
name: next_cron_time
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func NextCronTime(schedule CronSchedule, after time.Time) time.Time"
description: "Calcula la proxima ejecucion de un cron schedule despues de un tiempo dado. Avanza minuto a minuto saltando campos no coincidentes. Retorna zero time si no hay match en 366 dias (schedule imposible)."
tags: [cron, scheduling, time, next, pure]
uses_functions: [parse_cron_expr_go_core]
uses_types: [cron_schedule_go_core]
returns: []
returns_optional: false
error_type: ""
imports: [time]
tested: true
tests:
- "0 * * * * desde :30 retorna la proxima hora en punto"
- "@weekly desde viernes retorna proximo domingo a medianoche"
- "0 9 * * 1-5 desde viernes retorna proximo lunes a las 9"
- "schedule imposible retorna zero time"
test_file_path: "functions/core/next_cron_time_test.go"
file_path: "functions/core/next_cron_time.go"
---
## Ejemplo
```go
sched, _ := ParseCronExpr("0 * * * *")
after := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
next := NextCronTime(sched, after)
// next = 2024-01-15 15:00:00 UTC
weekdays, _ := ParseCronExpr("0 9 * * 1-5")
friday := time.Date(2024, 1, 19, 10, 0, 0, 0, time.UTC) // Friday
next2 := NextCronTime(weekdays, friday)
// next2 = 2024-01-22 09:00:00 UTC (Monday)
```
## Notas
Usa semantica AND para day_of_month y day_of_week: ambos campos deben coincidir. El limite de 366 dias evita loops infinitos en schedules imposibles (ej: 29 de febrero en un ano sin bisiesto). Devuelve zero time en lugar de error para mantener purity: false/zero es el idiom de Go para retornos opcionales sin error.
+72
View File
@@ -0,0 +1,72 @@
package core
import (
"testing"
"time"
)
func TestNextCronTime(t *testing.T) {
utc := time.UTC
t.Run("0 * * * * desde :30 retorna la proxima hora en punto", func(t *testing.T) {
sched, err := ParseCronExpr("0 * * * *")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Date(2024, 1, 15, 14, 30, 0, 0, utc)
got := NextCronTime(sched, after)
want := time.Date(2024, 1, 15, 15, 0, 0, 0, utc)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("@weekly desde viernes retorna proximo domingo a medianoche", func(t *testing.T) {
// @weekly = "0 0 * * 0" (Sunday)
sched, err := ParseCronExpr("@weekly")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 2024-01-19 is a Friday
after := time.Date(2024, 1, 19, 10, 0, 0, 0, utc)
got := NextCronTime(sched, after)
// Next Sunday = 2024-01-21
want := time.Date(2024, 1, 21, 0, 0, 0, 0, utc)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("0 9 * * 1-5 desde viernes retorna proximo lunes a las 9", func(t *testing.T) {
sched, err := ParseCronExpr("0 9 * * 1-5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 2024-01-19 is a Friday, after 9am so today is already past.
after := time.Date(2024, 1, 19, 10, 0, 0, 0, utc)
got := NextCronTime(sched, after)
// Next weekday = Monday 2024-01-22
want := time.Date(2024, 1, 22, 9, 0, 0, 0, utc)
if !got.Equal(want) {
t.Errorf("got %v, want %v", got, want)
}
})
t.Run("schedule imposible retorna zero time", func(t *testing.T) {
// 30 Feb does not exist — will exhaust 366-day limit quickly for a specific year.
// Use a schedule matching only Feb 30, which never occurs.
sched := CronSchedule{
Minute: []int{0},
Hour: []int{0},
DayOfMonth: []int{30},
Month: []int{2},
DayOfWeek: []int{0, 1, 2, 3, 4, 5, 6},
Raw: "0 0 30 2 *",
}
after := time.Date(2023, 3, 1, 0, 0, 0, 0, utc)
got := NextCronTime(sched, after)
if !got.IsZero() {
t.Errorf("expected zero time for impossible schedule, got %v", got)
}
})
}
+192
View File
@@ -0,0 +1,192 @@
package core
import (
"fmt"
"strconv"
"strings"
)
// aliases maps cron shorthand expressions to their 5-field equivalents.
var cronAliases = map[string]string{
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
}
// fieldLimits defines the valid [min, max] range for each cron field.
var cronFieldLimits = [5][2]int{
{0, 59}, // minute
{0, 23}, // hour
{1, 31}, // day of month
{1, 12}, // month
{0, 6}, // day of week
}
var cronFieldNames = [5]string{"minute", "hour", "day_of_month", "month", "day_of_week"}
// ParseCronExpr parses a standard 5-field cron expression into a CronSchedule.
// Supports *, ranges (1-5), lists (1,3,5), steps (*/15), and aliases (@hourly, @daily, @weekly, @monthly, @yearly).
// Returns an error for invalid expressions or out-of-range values.
func ParseCronExpr(expr string) (CronSchedule, error) {
expr = strings.TrimSpace(expr)
// Resolve aliases.
if expanded, ok := cronAliases[expr]; ok {
expr = expanded
}
fields := strings.Fields(expr)
if len(fields) != 5 {
return CronSchedule{}, fmt.Errorf("parse_cron_expr: expected 5 fields, got %d in %q", len(fields), expr)
}
var result [5][]int
for i, field := range fields {
lo, hi := cronFieldLimits[i][0], cronFieldLimits[i][1]
values, err := parseCronField(field, lo, hi)
if err != nil {
return CronSchedule{}, fmt.Errorf("parse_cron_expr: field %s: %w", cronFieldNames[i], err)
}
result[i] = values
}
return CronSchedule{
Minute: result[0],
Hour: result[1],
DayOfMonth: result[2],
Month: result[3],
DayOfWeek: result[4],
Raw: strings.TrimSpace(strings.Join(fields, " ")),
}, nil
}
// parseCronField expands a single cron field token into the list of matching integers.
func parseCronField(field string, lo, hi int) ([]int, error) {
// Handle wildcard.
if field == "*" {
return rangeSlice(lo, hi), nil
}
var values []int
seen := make(map[int]bool)
// Handle comma-separated list.
parts := strings.Split(field, ",")
for _, part := range parts {
expanded, err := parseCronPart(part, lo, hi)
if err != nil {
return nil, err
}
for _, v := range expanded {
if !seen[v] {
seen[v] = true
values = append(values, v)
}
}
}
// Sort.
sortInts(values)
return values, nil
}
// parseCronPart handles a single part: plain int, range (a-b), or step (*/n or a-b/n).
func parseCronPart(part string, lo, hi int) ([]int, error) {
// Step: */n or a-b/n
if idx := strings.Index(part, "/"); idx != -1 {
stepStr := part[idx+1:]
step, err := strconv.Atoi(stepStr)
if err != nil || step <= 0 {
return nil, fmt.Errorf("invalid step %q", stepStr)
}
base := part[:idx]
var start, end int
if base == "*" {
start, end = lo, hi
} else if dashIdx := strings.Index(base, "-"); dashIdx != -1 {
var err2 error
start, end, err2 = parseRange(base, lo, hi)
if err2 != nil {
return nil, err2
}
} else {
v, err2 := parseValue(base, lo, hi)
if err2 != nil {
return nil, err2
}
start, end = v, hi
}
var result []int
for v := start; v <= end; v += step {
result = append(result, v)
}
return result, nil
}
// Range: a-b
if strings.Contains(part, "-") {
start, end, err := parseRange(part, lo, hi)
if err != nil {
return nil, err
}
return rangeSlice(start, end), nil
}
// Plain integer.
v, err := parseValue(part, lo, hi)
if err != nil {
return nil, err
}
return []int{v}, nil
}
func parseRange(s string, lo, hi int) (int, int, error) {
parts := strings.SplitN(s, "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range %q", s)
}
start, err := parseValue(parts[0], lo, hi)
if err != nil {
return 0, 0, err
}
end, err := parseValue(parts[1], lo, hi)
if err != nil {
return 0, 0, err
}
if start > end {
return 0, 0, fmt.Errorf("range start %d > end %d", start, end)
}
return start, end, nil
}
func parseValue(s string, lo, hi int) (int, error) {
v, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid value %q: not an integer", s)
}
if v < lo || v > hi {
return 0, fmt.Errorf("value %d out of range [%d, %d]", v, lo, hi)
}
return v, nil
}
func rangeSlice(lo, hi int) []int {
s := make([]int, hi-lo+1)
for i := range s {
s[i] = lo + i
}
return s
}
// sortInts is a simple insertion sort for small slices (avoids importing sort).
func sortInts(a []int) {
for i := 1; i < len(a); i++ {
for j := i; j > 0 && a[j] < a[j-1]; j-- {
a[j], a[j-1] = a[j-1], a[j]
}
}
}
+45
View File
@@ -0,0 +1,45 @@
---
name: parse_cron_expr
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func ParseCronExpr(expr string) (CronSchedule, error)"
description: "Parsea una expresion cron estandar de 5 campos en un CronSchedule con valores expandidos. Soporta *, rangos (1-5), listas (1,3,5), pasos (*/15) y aliases (@hourly, @daily, @weekly, @monthly, @yearly). No soporta segundos ni years estilo Quartz."
tags: [cron, scheduling, parsing, time, pure]
uses_functions: []
uses_types: [cron_schedule_go_core]
returns: []
returns_optional: false
error_type: ""
imports: [fmt, strconv, strings]
tested: true
tests:
- "*/15 expande minutos a [0 15 30 45]"
- "@daily resuelve a 0 0 en todos los campos restantes"
- "0 9 1,15 * * expande dias a [1 15]"
- "0 9 * * 1-5 expande dia de semana a [1 2 3 4 5]"
- "expresion con 4 campos retorna error"
- "minuto fuera de rango retorna error"
test_file_path: "functions/core/parse_cron_expr_test.go"
file_path: "functions/core/parse_cron_expr.go"
---
## Ejemplo
```go
sched, err := ParseCronExpr("*/15 * * * *")
// sched.Minute = [0, 15, 30, 45]
// sched.Hour = [0, 1, ..., 23]
sched2, _ := ParseCronExpr("@daily")
// sched2.Minute = [0], sched2.Hour = [0]
sched3, _ := ParseCronExpr("0 9 * * 1-5")
// sched3.DayOfWeek = [1, 2, 3, 4, 5] (lunes a viernes)
```
## Notas
Funcion pura. Cada campo cron se expande a la lista completa de valores enteros validos. Los aliases se resuelven antes del parseo. Los limites son: minute [0,59], hour [0,23], day_of_month [1,31], month [1,12], day_of_week [0,6] (0=domingo).
+81
View File
@@ -0,0 +1,81 @@
package core
import (
"reflect"
"testing"
)
func TestParseCronExpr(t *testing.T) {
t.Run("*/15 expande minutos a [0 15 30 45]", func(t *testing.T) {
sched, err := ParseCronExpr("*/15 * * * *")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []int{0, 15, 30, 45}
if !reflect.DeepEqual(sched.Minute, want) {
t.Errorf("Minute = %v, want %v", sched.Minute, want)
}
// Hour should be all 24 hours
if len(sched.Hour) != 24 {
t.Errorf("Hour len = %d, want 24", len(sched.Hour))
}
})
t.Run("@daily resuelve a 0 0 en todos los campos restantes", func(t *testing.T) {
sched, err := ParseCronExpr("@daily")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(sched.Minute, []int{0}) {
t.Errorf("Minute = %v, want [0]", sched.Minute)
}
if !reflect.DeepEqual(sched.Hour, []int{0}) {
t.Errorf("Hour = %v, want [0]", sched.Hour)
}
// DayOfMonth should be all days
if len(sched.DayOfMonth) != 31 {
t.Errorf("DayOfMonth len = %d, want 31", len(sched.DayOfMonth))
}
})
t.Run("0 9 1,15 * * expande dias a [1 15]", func(t *testing.T) {
sched, err := ParseCronExpr("0 9 1,15 * *")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(sched.Minute, []int{0}) {
t.Errorf("Minute = %v, want [0]", sched.Minute)
}
if !reflect.DeepEqual(sched.Hour, []int{9}) {
t.Errorf("Hour = %v, want [9]", sched.Hour)
}
if !reflect.DeepEqual(sched.DayOfMonth, []int{1, 15}) {
t.Errorf("DayOfMonth = %v, want [1, 15]", sched.DayOfMonth)
}
})
t.Run("0 9 * * 1-5 expande dia de semana a [1 2 3 4 5]", func(t *testing.T) {
sched, err := ParseCronExpr("0 9 * * 1-5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []int{1, 2, 3, 4, 5}
if !reflect.DeepEqual(sched.DayOfWeek, want) {
t.Errorf("DayOfWeek = %v, want %v", sched.DayOfWeek, want)
}
})
t.Run("expresion con 4 campos retorna error", func(t *testing.T) {
_, err := ParseCronExpr("0 9 * *")
if err == nil {
t.Error("expected error for 4-field expression, got nil")
}
})
t.Run("minuto fuera de rango retorna error", func(t *testing.T) {
_, err := ParseCronExpr("60 * * * *")
if err == nil {
t.Error("expected error for minute=60, got nil")
}
})
}
+233
View File
@@ -0,0 +1,233 @@
package core
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// ValidateStructFields validates fields of a map against declarative rules.
// Each rule is a comma-separated string like "required,type=string,min=1,max=100".
//
// Supported rules:
// - required — field must exist and not be nil or ""
// - type=string|int|float|bool — validate underlying Go type
// - min=N, max=N — for numeric values
// - minlen=N, maxlen=N — for string values
// - oneof=a|b|c — value must be one of the listed options
// - pattern=regex — for string values
//
// Returns (valid, errors). Errors accumulate — all fields are checked.
func ValidateStructFields(data map[string]any, rules map[string]string) (bool, []string) {
var errs []string
for field, ruleStr := range rules {
parts := strings.Split(ruleStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if err := applyRule(data, field, part); err != "" {
errs = append(errs, err)
// stop further checks on this field if required failed
if part == "required" {
break
}
}
}
}
return len(errs) == 0, errs
}
// applyRule applies a single rule to a field and returns an error string or "".
func applyRule(data map[string]any, field, rule string) string {
switch {
case rule == "required":
val, ok := data[field]
if !ok || val == nil {
return fmt.Sprintf("%s: required field missing", field)
}
if s, ok := val.(string); ok && s == "" {
return fmt.Sprintf("%s: required field is empty string", field)
}
return ""
case strings.HasPrefix(rule, "type="):
expectedType := rule[len("type="):]
val, ok := data[field]
if !ok || val == nil {
return "" // absence handled by required
}
return checkType(field, val, expectedType)
case strings.HasPrefix(rule, "min="):
n, err := strconv.ParseFloat(rule[len("min="):], 64)
if err != nil {
return fmt.Sprintf("%s: invalid rule min value: %s", field, rule)
}
val, ok := data[field]
if !ok || val == nil {
return ""
}
f, ok := toFloat(val)
if !ok {
return fmt.Sprintf("%s: cannot apply min to non-numeric value", field)
}
if f < n {
return fmt.Sprintf("%s: %v < min %v", field, val, n)
}
return ""
case strings.HasPrefix(rule, "max="):
n, err := strconv.ParseFloat(rule[len("max="):], 64)
if err != nil {
return fmt.Sprintf("%s: invalid rule max value: %s", field, rule)
}
val, ok := data[field]
if !ok || val == nil {
return ""
}
f, ok := toFloat(val)
if !ok {
return fmt.Sprintf("%s: cannot apply max to non-numeric value", field)
}
if f > n {
return fmt.Sprintf("%s: %v > max %v", field, val, n)
}
return ""
case strings.HasPrefix(rule, "minlen="):
n, err := strconv.Atoi(rule[len("minlen="):])
if err != nil {
return fmt.Sprintf("%s: invalid rule minlen value: %s", field, rule)
}
val, ok := data[field]
if !ok || val == nil {
return ""
}
s, ok := val.(string)
if !ok {
return fmt.Sprintf("%s: cannot apply minlen to non-string value", field)
}
if len(s) < n {
return fmt.Sprintf("%s: length %d < minlen %d", field, len(s), n)
}
return ""
case strings.HasPrefix(rule, "maxlen="):
n, err := strconv.Atoi(rule[len("maxlen="):])
if err != nil {
return fmt.Sprintf("%s: invalid rule maxlen value: %s", field, rule)
}
val, ok := data[field]
if !ok || val == nil {
return ""
}
s, ok := val.(string)
if !ok {
return fmt.Sprintf("%s: cannot apply maxlen to non-string value", field)
}
if len(s) > n {
return fmt.Sprintf("%s: length %d > maxlen %d", field, len(s), n)
}
return ""
case strings.HasPrefix(rule, "oneof="):
options := strings.Split(rule[len("oneof="):], "|")
val, ok := data[field]
if !ok || val == nil {
return ""
}
sval := fmt.Sprintf("%v", val)
for _, opt := range options {
if sval == opt {
return ""
}
}
return fmt.Sprintf("%s: value %q not in oneof [%s]", field, sval, rule[len("oneof="):])
case strings.HasPrefix(rule, "pattern="):
pat := rule[len("pattern="):]
val, ok := data[field]
if !ok || val == nil {
return ""
}
s, ok := val.(string)
if !ok {
return fmt.Sprintf("%s: cannot apply pattern to non-string value", field)
}
re, err := regexp.Compile(pat)
if err != nil {
return fmt.Sprintf("%s: invalid pattern %q: %v", field, pat, err)
}
if !re.MatchString(s) {
return fmt.Sprintf("%s: value %q does not match pattern %q", field, s, pat)
}
return ""
default:
return fmt.Sprintf("%s: unknown rule %q", field, rule)
}
}
func checkType(field string, val any, expected string) string {
var ok bool
switch expected {
case "string":
_, ok = val.(string)
case "int":
switch val.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
ok = true
}
case "float":
switch val.(type) {
case float32, float64:
ok = true
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
ok = true // integers are valid floats
}
case "bool":
_, ok = val.(bool)
default:
return fmt.Sprintf("%s: unknown type rule %q", field, expected)
}
if !ok {
return fmt.Sprintf("%s: expected type %s, got %T", field, expected, val)
}
return ""
}
func toFloat(val any) (float64, bool) {
switch v := val.(type) {
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case float32:
return float64(v), true
case float64:
return v, true
}
return 0, false
}
+64
View File
@@ -0,0 +1,64 @@
---
name: validate_struct_fields
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func ValidateStructFields(data map[string]any, rules map[string]string) (bool, []string)"
description: "Valida campos de un map[string]any contra reglas declarativas tipo 'required,min=1,max=100,type=string'. Soporta required, type, min/max, minlen/maxlen, oneof, pattern. Pensado para validar metadata de entities en operations.db o resultados de queries sin definir structs Go. Acumula todos los errores."
tags: [validation, map, rules, pure, core, operations]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [fmt, regexp, strconv, strings]
tested: true
tests:
- "campo required presente y ausente"
- "type validation string como int falla"
- "numeric ranges"
- "string lengths"
- "oneof validation"
- "pattern matching"
- "multiples reglas combinadas"
- "map vacio con reglas required"
test_file_path: "functions/core/validate_struct_fields_test.go"
file_path: "functions/core/validate_struct_fields.go"
---
## Ejemplo
```go
data := map[string]any{
"name": "Alice",
"age": 30,
"status": "active",
"email": "alice@example.com",
}
rules := map[string]string{
"name": "required,type=string,minlen=2,maxlen=100",
"age": "required,type=int,min=0,max=150",
"status": "required,oneof=active|inactive|pending",
"email": `required,type=string,pattern=^[^@]+@[^@]+$`,
}
valid, errs := ValidateStructFields(data, rules)
// valid = true, errs = []
data2 := map[string]any{"name": "A", "age": 200, "status": "deleted"}
valid2, errs2 := ValidateStructFields(data2, rules)
// valid2 = false
// errs2 = [
// "name: length 1 < minlen 2",
// "age: 200 > max 150",
// "status: value \"deleted\" not in oneof [active|inactive|pending]",
// "email: required field missing",
// ]
```
## Notas
Funcion pura. Solo usa stdlib (fmt, regexp, strconv, strings). Las reglas se evaluan en orden y se acumulan todos los errores. Si `required` falla, se omiten las reglas restantes de ese campo para evitar falsos positivos. Tipos Go aceptados para type=int: int, int8..int64, uint..uint64. Tipo float acepta enteros tambien. Pattern compila el regex en cada llamada — para uso intensivo cachear los regexp compilados fuera.
@@ -0,0 +1,131 @@
package core
import (
"strings"
"testing"
)
func TestValidateStructFields(t *testing.T) {
t.Run("campo required presente y ausente", func(t *testing.T) {
rules := map[string]string{"name": "required"}
valid, errs := ValidateStructFields(map[string]any{"name": "Alice"}, rules)
if !valid || len(errs) != 0 {
t.Errorf("expected valid, got errors: %v", errs)
}
valid2, errs2 := ValidateStructFields(map[string]any{}, rules)
if valid2 || len(errs2) == 0 {
t.Errorf("expected invalid for missing required field")
}
})
t.Run("type validation string como int falla", func(t *testing.T) {
rules := map[string]string{"count": "type=int"}
valid, _ := ValidateStructFields(map[string]any{"count": 5}, rules)
if !valid {
t.Error("expected int 5 to pass type=int")
}
valid2, errs2 := ValidateStructFields(map[string]any{"count": "five"}, rules)
if valid2 || len(errs2) == 0 {
t.Error("expected string to fail type=int")
}
})
t.Run("numeric ranges", func(t *testing.T) {
rules := map[string]string{"score": "min=0,max=100"}
valid, _ := ValidateStructFields(map[string]any{"score": 50}, rules)
if !valid {
t.Error("expected 50 to pass min=0,max=100")
}
valid2, errs2 := ValidateStructFields(map[string]any{"score": 150}, rules)
if valid2 || !strings.Contains(errs2[0], "max") {
t.Errorf("expected max violation, got: %v", errs2)
}
valid3, errs3 := ValidateStructFields(map[string]any{"score": -1}, rules)
if valid3 || !strings.Contains(errs3[0], "min") {
t.Errorf("expected min violation, got: %v", errs3)
}
})
t.Run("string lengths", func(t *testing.T) {
rules := map[string]string{"tag": "minlen=2,maxlen=10"}
valid, _ := ValidateStructFields(map[string]any{"tag": "go"}, rules)
if !valid {
t.Error("expected 'go' to pass minlen=2,maxlen=10")
}
valid2, errs2 := ValidateStructFields(map[string]any{"tag": "a"}, rules)
if valid2 || !strings.Contains(errs2[0], "minlen") {
t.Errorf("expected minlen violation, got: %v", errs2)
}
valid3, errs3 := ValidateStructFields(map[string]any{"tag": "averylongtag"}, rules)
if valid3 || !strings.Contains(errs3[0], "maxlen") {
t.Errorf("expected maxlen violation, got: %v", errs3)
}
})
t.Run("oneof validation", func(t *testing.T) {
rules := map[string]string{"status": "oneof=active|inactive|pending"}
valid, _ := ValidateStructFields(map[string]any{"status": "active"}, rules)
if !valid {
t.Error("expected 'active' to pass oneof")
}
valid2, errs2 := ValidateStructFields(map[string]any{"status": "deleted"}, rules)
if valid2 || len(errs2) == 0 {
t.Errorf("expected oneof violation, got: %v", errs2)
}
})
t.Run("pattern matching", func(t *testing.T) {
rules := map[string]string{"email": `pattern=^[^@]+@[^@]+\.[^@]+$`}
valid, _ := ValidateStructFields(map[string]any{"email": "user@example.com"}, rules)
if !valid {
t.Error("expected valid email to pass pattern")
}
valid2, errs2 := ValidateStructFields(map[string]any{"email": "not-an-email"}, rules)
if valid2 || !strings.Contains(errs2[0], "pattern") {
t.Errorf("expected pattern violation, got: %v", errs2)
}
})
t.Run("multiples reglas combinadas", func(t *testing.T) {
rules := map[string]string{
"name": "required,type=string,minlen=2,maxlen=50",
"score": "required,type=float,min=0,max=10",
}
valid, _ := ValidateStructFields(map[string]any{"name": "Alice", "score": float64(8.5)}, rules)
if !valid {
t.Error("expected all rules to pass")
}
valid2, errs2 := ValidateStructFields(map[string]any{"name": "A", "score": float64(11)}, rules)
if valid2 || len(errs2) < 2 {
t.Errorf("expected at least 2 errors, got: %v", errs2)
}
})
t.Run("map vacio con reglas required", func(t *testing.T) {
rules := map[string]string{
"id": "required",
"name": "required",
}
valid, errs := ValidateStructFields(map[string]any{}, rules)
if valid || len(errs) < 2 {
t.Errorf("expected 2 required errors, got: %v", errs)
}
})
}
+96
View File
@@ -0,0 +1,96 @@
package datascience
import "fmt"
// DiffEntities compares two snapshots of entities and returns field-level differences.
// Detects added, removed, modified, and unchanged entities.
// ignoreFields specifies fields to exclude from comparison (defaults to ["created_at", "updated_at"] when nil).
func DiffEntities(before, after []map[string]any, key string, ignoreFields []string) map[string]any {
if ignoreFields == nil {
ignoreFields = []string{"created_at", "updated_at"}
}
ignoreSet := make(map[string]bool, len(ignoreFields))
for _, f := range ignoreFields {
ignoreSet[f] = true
}
beforeMap := make(map[string]map[string]any, len(before))
for _, e := range before {
if k, ok := e[key]; ok {
beforeMap[fmt.Sprintf("%v", k)] = e
}
}
afterMap := make(map[string]map[string]any, len(after))
for _, e := range after {
if k, ok := e[key]; ok {
afterMap[fmt.Sprintf("%v", k)] = e
}
}
added := []map[string]any{}
for k, e := range afterMap {
if _, exists := beforeMap[k]; !exists {
added = append(added, e)
}
}
removed := []map[string]any{}
for k, e := range beforeMap {
if _, exists := afterMap[k]; !exists {
removed = append(removed, e)
}
}
modified := []map[string]any{}
unchanged := 0
for k, b := range beforeMap {
a, exists := afterMap[k]
if !exists {
continue
}
// Collect all fields from both entities
allFields := make(map[string]bool)
for f := range b {
allFields[f] = true
}
for f := range a {
allFields[f] = true
}
changes := map[string]any{}
for field := range allFields {
if ignoreSet[field] || field == key {
continue
}
oldVal := b[field]
newVal := a[field]
if fmt.Sprintf("%v", oldVal) != fmt.Sprintf("%v", newVal) {
changes[field] = map[string]any{"old": oldVal, "new": newVal}
}
}
if len(changes) > 0 {
modified = append(modified, map[string]any{"key": k, "changes": changes})
} else {
unchanged++
}
}
nAdded := len(added)
nRemoved := len(removed)
nModified := len(modified)
summary := fmt.Sprintf("%d added, %d removed, %d modified, %d unchanged",
nAdded, nRemoved, nModified, unchanged)
return map[string]any{
"added": added,
"removed": removed,
"modified": modified,
"unchanged": unchanged,
"summary": summary,
}
}
+52
View File
@@ -0,0 +1,52 @@
---
name: diff_entities
kind: function
lang: go
domain: datascience
version: "1.0.0"
purity: pure
signature: "func DiffEntities(before, after []map[string]any, key string, ignoreFields []string) map[string]any"
description: "Compara dos snapshots de entities y devuelve diferencias campo a campo. Detecta añadidas, eliminadas, modificadas e inalteradas. Ignora created_at y updated_at por defecto (pasar nil para usar defaults)."
tags: [datascience, diff, entities, operations, snapshot, comparison]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["fmt"]
tested: true
tests:
- "entity añadida"
- "entity eliminada"
- "entity modificada con detalle de campos"
- "entities identicas → unchanged"
- "ignore_fields funciona"
- "lista vacia vs lista con datos"
- "summary format correcto"
test_file_path: "functions/datascience/diff_entities_test.go"
file_path: "functions/datascience/diff_entities.go"
---
## Ejemplo
```go
before := []map[string]any{
{"id": "1", "name": "Alice", "status": "active"},
{"id": "2", "name": "Bob"},
}
after := []map[string]any{
{"id": "1", "name": "Alice", "status": "inactive"},
{"id": "3", "name": "Carol"},
}
result := DiffEntities(before, after, "id", nil)
// result["summary"] = "1 added, 1 removed, 1 modified, 0 unchanged"
// result["added"] = [{"id": "3", "name": "Carol"}]
// result["removed"] = [{"id": "2", "name": "Bob"}]
// result["modified"] = [{"key": "1", "changes": {"status": {"old": "active", "new": "inactive"}}}]
```
## Notas
Funcion pura. Compara valores con fmt.Sprintf("%v", ...) para manejar tipos heterogeneos en map[string]any.
ignoreFields nil usa los defaults ["created_at", "updated_at"]. Para no ignorar ningun campo, pasar []string{}.
Semantica identica a diff_entities_py_datascience, permite comparar resultados entre ejecuciones del mismo pipeline.
+138
View File
@@ -0,0 +1,138 @@
package datascience
import (
"testing"
)
func TestDiffEntities(t *testing.T) {
t.Run("entity añadida", func(t *testing.T) {
before := []map[string]any{
{"id": "1", "name": "Alice"},
}
after := []map[string]any{
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
}
result := DiffEntities(before, after, "id", nil)
added := result["added"].([]map[string]any)
if len(added) != 1 {
t.Errorf("expected 1 added, got %d", len(added))
}
if added[0]["id"] != "2" {
t.Errorf("expected added id=2, got %v", added[0]["id"])
}
if result["unchanged"].(int) != 1 {
t.Errorf("expected 1 unchanged, got %v", result["unchanged"])
}
})
t.Run("entity eliminada", func(t *testing.T) {
before := []map[string]any{
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
}
after := []map[string]any{
{"id": "1", "name": "Alice"},
}
result := DiffEntities(before, after, "id", nil)
removed := result["removed"].([]map[string]any)
if len(removed) != 1 {
t.Errorf("expected 1 removed, got %d", len(removed))
}
if removed[0]["id"] != "2" {
t.Errorf("expected removed id=2, got %v", removed[0]["id"])
}
})
t.Run("entity modificada con detalle de campos", func(t *testing.T) {
before := []map[string]any{
{"id": "1", "name": "Alice", "status": "active"},
}
after := []map[string]any{
{"id": "1", "name": "Alice", "status": "inactive"},
}
result := DiffEntities(before, after, "id", nil)
modified := result["modified"].([]map[string]any)
if len(modified) != 1 {
t.Errorf("expected 1 modified, got %d", len(modified))
}
changes := modified[0]["changes"].(map[string]any)
statusChange, ok := changes["status"].(map[string]any)
if !ok {
t.Fatalf("expected status change, got %v", changes)
}
if statusChange["old"] != "active" {
t.Errorf("expected old=active, got %v", statusChange["old"])
}
if statusChange["new"] != "inactive" {
t.Errorf("expected new=inactive, got %v", statusChange["new"])
}
})
t.Run("entities identicas → unchanged", func(t *testing.T) {
entities := []map[string]any{
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
}
result := DiffEntities(entities, entities, "id", nil)
if result["unchanged"].(int) != 2 {
t.Errorf("expected 2 unchanged, got %v", result["unchanged"])
}
if len(result["added"].([]map[string]any)) != 0 {
t.Errorf("expected 0 added")
}
if len(result["modified"].([]map[string]any)) != 0 {
t.Errorf("expected 0 modified")
}
})
t.Run("ignore_fields funciona", func(t *testing.T) {
before := []map[string]any{
{"id": "1", "name": "Alice", "updated_at": "2024-01-01"},
}
after := []map[string]any{
{"id": "1", "name": "Alice", "updated_at": "2024-06-01"},
}
// Default ignores updated_at
result := DiffEntities(before, after, "id", nil)
if result["unchanged"].(int) != 1 {
t.Errorf("expected 1 unchanged (updated_at ignored), got %v", result["unchanged"])
}
modified := result["modified"].([]map[string]any)
if len(modified) != 0 {
t.Errorf("expected 0 modified when updated_at is ignored, got %d", len(modified))
}
})
t.Run("lista vacia vs lista con datos", func(t *testing.T) {
before := []map[string]any{}
after := []map[string]any{
{"id": "1", "name": "Alice"},
}
result := DiffEntities(before, after, "id", nil)
added := result["added"].([]map[string]any)
if len(added) != 1 {
t.Errorf("expected 1 added, got %d", len(added))
}
if result["unchanged"].(int) != 0 {
t.Errorf("expected 0 unchanged")
}
})
t.Run("summary format correcto", func(t *testing.T) {
before := []map[string]any{
{"id": "1", "name": "Alice"},
{"id": "3", "name": "Carol"},
}
after := []map[string]any{
{"id": "1", "name": "Alice Changed"},
{"id": "2", "name": "Bob"},
}
result := DiffEntities(before, after, "id", nil)
summary := result["summary"].(string)
expected := "1 added, 1 removed, 1 modified, 0 unchanged"
if summary != expected {
t.Errorf("expected summary %q, got %q", expected, summary)
}
})
}
+110
View File
@@ -0,0 +1,110 @@
package datascience
// Pivot transforma datos del formato largo al formato ancho (pivot table).
// Agrupa por index, expande los valores unicos de columns como nuevas columnas
// y agrega values con la funcion indicada.
// Funciones de agregacion soportadas: sum, count, mean, min, max, first, last.
// Valores numericos faltantes se rellenan con 0.
func Pivot(rows []map[string]any, index, columns, values, agg string) []map[string]any {
// Mantener orden de aparicion de index y column values
indexOrder := []any{}
seenIndex := map[any]bool{}
colOrder := []any{}
seenCols := map[any]bool{}
for _, row := range rows {
idx := row[index]
col := row[columns]
if !seenIndex[idx] {
seenIndex[idx] = true
indexOrder = append(indexOrder, idx)
}
if !seenCols[col] {
seenCols[col] = true
colOrder = append(colOrder, col)
}
}
// Acumular: groups[indexVal][colVal] = lista de valores
type key struct{ idx, col any }
groups := map[key][]any{}
for _, row := range rows {
idx := row[index]
col := row[columns]
val := row[values]
if val != nil {
k := key{idx, col}
groups[k] = append(groups[k], val)
}
}
aggregate := func(vals []any, fn string) any {
if len(vals) == 0 {
return 0
}
switch fn {
case "count":
return len(vals)
case "first":
return vals[0]
case "last":
return vals[len(vals)-1]
}
// Funciones numericas: sum, mean, min, max
toFloat := func(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case int32:
return float64(n)
}
return 0
}
sum := 0.0
mn := toFloat(vals[0])
mx := toFloat(vals[0])
for _, v := range vals {
f := toFloat(v)
sum += f
if f < mn {
mn = f
}
if f > mx {
mx = f
}
}
switch fn {
case "sum":
return sum
case "mean":
return sum / float64(len(vals))
case "min":
return mn
case "max":
return mx
}
return sum
}
result := make([]map[string]any, 0, len(indexOrder))
for _, idx := range indexOrder {
record := map[string]any{index: idx}
for _, col := range colOrder {
k := key{idx, col}
vals := groups[k]
if len(vals) > 0 {
record[col.(string)] = aggregate(vals, agg)
} else {
record[col.(string)] = 0
}
}
result = append(result, record)
}
return result
}
+43
View File
@@ -0,0 +1,43 @@
---
name: pivot
kind: function
lang: go
domain: datascience
version: "1.0.0"
purity: pure
signature: "func Pivot(rows []map[string]any, index, columns, values, agg string) []map[string]any"
description: "Pivot table sin dependencias. Agrupa por index, expande valores unicos de columns como nuevas columnas y agrega values con la funcion indicada (sum, count, mean, min, max, first, last). Valores faltantes se rellenan con 0."
tags: [datascience, tabular, pivot, transform, aggregation, go]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests:
- "Pivot basico con sum"
- "Pivot con count y mean"
- "Valores faltantes rellenados con 0"
- "Una sola fila"
- "Multiples valores por celda requieren agregacion"
test_file_path: "functions/datascience/pivot_test.go"
file_path: "functions/datascience/pivot.go"
---
## Ejemplo
```go
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 10},
{"region": "US", "product": "B", "sales": 20},
{"region": "EU", "product": "A", "sales": 15},
}
result := Pivot(rows, "region", "product", "sales", "sum")
// [{"region": "US", "A": 10.0, "B": 20.0}, {"region": "EU", "A": 15.0, "B": 0}]
```
## Notas
Funcion pura sin dependencias externas. Usa map[string]any para trabajar con datos JSON/SQL deserializados.
Las agregaciones numericas (sum, mean, min, max) convierten valores a float64 via type assertion.
+111
View File
@@ -0,0 +1,111 @@
package datascience
import (
"testing"
)
func TestPivot(t *testing.T) {
t.Run("Pivot basico con sum", func(t *testing.T) {
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 10},
{"region": "US", "product": "B", "sales": 20},
{"region": "EU", "product": "A", "sales": 15},
}
result := Pivot(rows, "region", "product", "sales", "sum")
if len(result) != 2 {
t.Fatalf("got %d rows, want 2", len(result))
}
var us, eu map[string]any
for _, r := range result {
if r["region"] == "US" {
us = r
} else {
eu = r
}
}
if us["A"] != 10 {
t.Errorf("US.A: got %v, want 10", us["A"])
}
if us["B"] != 20 {
t.Errorf("US.B: got %v, want 20", us["B"])
}
if eu["A"] != 15 {
t.Errorf("EU.A: got %v, want 15", eu["A"])
}
if eu["B"] != 0 {
t.Errorf("EU.B: got %v, want 0", eu["B"])
}
})
t.Run("Pivot con count y mean", func(t *testing.T) {
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 10},
{"region": "US", "product": "A", "sales": 20},
{"region": "EU", "product": "A", "sales": 15},
}
resultCount := Pivot(rows, "region", "product", "sales", "count")
for _, r := range resultCount {
if r["region"] == "US" && r["A"] != 2 {
t.Errorf("count US.A: got %v, want 2", r["A"])
}
}
resultMean := Pivot(rows, "region", "product", "sales", "mean")
for _, r := range resultMean {
if r["region"] == "US" {
mean, ok := r["A"].(float64)
if !ok || mean != 15.0 {
t.Errorf("mean US.A: got %v, want 15.0", r["A"])
}
}
}
})
t.Run("Valores faltantes rellenados con 0", func(t *testing.T) {
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 5},
{"region": "EU", "product": "B", "sales": 8},
}
result := Pivot(rows, "region", "product", "sales", "sum")
for _, r := range result {
if r["region"] == "US" && r["B"] != 0 {
t.Errorf("US.B: got %v, want 0", r["B"])
}
if r["region"] == "EU" && r["A"] != 0 {
t.Errorf("EU.A: got %v, want 0", r["A"])
}
}
})
t.Run("Una sola fila", func(t *testing.T) {
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 42},
}
result := Pivot(rows, "region", "product", "sales", "sum")
if len(result) != 1 {
t.Fatalf("got %d rows, want 1", len(result))
}
if result[0]["A"] != 42 {
t.Errorf("got %v, want 42", result[0]["A"])
}
})
t.Run("Multiples valores por celda requieren agregacion", func(t *testing.T) {
rows := []map[string]any{
{"region": "US", "product": "A", "sales": 10},
{"region": "US", "product": "A", "sales": 30},
}
resultSum := Pivot(rows, "region", "product", "sales", "sum")
if resultSum[0]["A"] != 40.0 {
t.Errorf("sum: got %v, want 40.0", resultSum[0]["A"])
}
resultMin := Pivot(rows, "region", "product", "sales", "min")
if resultMin[0]["A"] != 10.0 {
t.Errorf("min: got %v, want 10.0", resultMin[0]["A"])
}
resultMax := Pivot(rows, "region", "product", "sales", "max")
if resultMax[0]["A"] != 30.0 {
t.Errorf("max: got %v, want 30.0", resultMax[0]["A"])
}
})
}
+156
View File
@@ -0,0 +1,156 @@
package infra
import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
// SQLiteCache es un cache key-value persistido en SQLite con soporte de TTL.
// Valores almacenados como JSON serializado. El caller es responsable de
// deserializar el []byte retornado por Get.
// Seguro para uso concurrente.
type SQLiteCache struct {
db *sql.DB
namespace string
mu sync.Mutex
}
const sqliteCacheSchema = `
CREATE TABLE IF NOT EXISTS cache (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL,
PRIMARY KEY (namespace, key)
);`
// CacheToSQLite abre (o crea) una base de datos SQLite en dbPath y retorna
// un SQLiteCache para el namespace dado.
func CacheToSQLite(dbPath, namespace string) (*SQLiteCache, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("cache_to_sqlite: open db: %w", err)
}
if _, err := db.Exec(sqliteCacheSchema); err != nil {
db.Close()
return nil, fmt.Errorf("cache_to_sqlite: create schema: %w", err)
}
return &SQLiteCache{db: db, namespace: namespace}, nil
}
// evictExpired elimina las entradas expiradas del namespace. Debe llamarse
// con el mutex ya tomado.
func (c *SQLiteCache) evictExpired() {
now := float64(time.Now().UnixNano()) / 1e9
c.db.Exec(
"DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?",
c.namespace, now,
)
}
// Get retorna el valor asociado a key y true, o nil y false si no existe o
// esta expirado. El []byte contiene JSON que el caller puede deserializar.
func (c *SQLiteCache) Get(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.evictExpired()
var value string
err := c.db.QueryRow(
"SELECT value FROM cache WHERE namespace = ? AND key = ?",
c.namespace, key,
).Scan(&value)
if err != nil {
return nil, false
}
return []byte(value), true
}
// Set almacena value (JSON bytes) bajo key. ttl=0 significa sin expiracion.
func (c *SQLiteCache) Set(key string, value []byte, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
now := float64(time.Now().UnixNano()) / 1e9
var expiresAt any
if ttl > 0 {
expiresAt = now + ttl.Seconds()
}
_, err := c.db.Exec(
`INSERT INTO cache (namespace, key, value, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(namespace, key) DO UPDATE SET
value = excluded.value,
created_at = excluded.created_at,
expires_at = excluded.expires_at`,
c.namespace, key, string(value), now, expiresAt,
)
if err != nil {
return fmt.Errorf("cache set: %w", err)
}
return nil
}
// Delete elimina la entrada asociada a key. Retorna error si falla la query.
func (c *SQLiteCache) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.db.Exec(
"DELETE FROM cache WHERE namespace = ? AND key = ?",
c.namespace, key,
)
if err != nil {
return fmt.Errorf("cache delete: %w", err)
}
return nil
}
// Clear elimina todas las entradas del namespace. Retorna el numero de filas
// eliminadas.
func (c *SQLiteCache) Clear() (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
res, err := c.db.Exec(
"DELETE FROM cache WHERE namespace = ?",
c.namespace,
)
if err != nil {
return 0, fmt.Errorf("cache clear: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// GetOrSet retorna el valor cacheado o llama factory() para obtenerlo,
// lo almacena con el ttl dado y lo retorna.
func (c *SQLiteCache) GetOrSet(key string, factory func() ([]byte, error), ttl time.Duration) ([]byte, error) {
if v, ok := c.Get(key); ok {
return v, nil
}
value, err := factory()
if err != nil {
return nil, fmt.Errorf("cache get_or_set factory: %w", err)
}
if err := c.Set(key, value, ttl); err != nil {
return nil, err
}
return value, nil
}
// SetJSON serializa v como JSON y lo almacena bajo key.
func (c *SQLiteCache) SetJSON(key string, v any, ttl time.Duration) error {
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("cache set_json marshal: %w", err)
}
return c.Set(key, b, ttl)
}
// Close cierra la conexion a la base de datos.
func (c *SQLiteCache) Close() error {
return c.db.Close()
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cache_to_sqlite
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CacheToSQLite(dbPath, namespace string) (*SQLiteCache, error)"
description: "Cache key-value persistido en SQLite con TTL y lazy eviction. Valores almacenados como JSON bytes; el caller serializa y deserializa. Thread-safe con sync.Mutex. Soporta Get, Set, Delete, Clear y GetOrSet."
tags: [cache, sqlite, persistence, ttl, key-value, concurrent]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql", "encoding/json", "sync", "time", "fmt"]
tested: true
tests:
- "Set/Get basico"
- "TTL expirado"
- "GetOrSet con factory"
- "Concurrencia (goroutines)"
test_file_path: "functions/infra/cache_to_sqlite_test.go"
file_path: "functions/infra/cache_to_sqlite.go"
---
## Ejemplo
```go
cache, err := infra.CacheToSQLite("my_cache.db", "default")
if err != nil {
log.Fatal(err)
}
defer cache.Close()
// Almacenar JSON bytes con TTL de 1 hora
payload, _ := json.Marshal(map[string]string{"result": "ok"})
cache.Set("key1", payload, time.Hour)
// Recuperar
if v, ok := cache.Get("key1"); ok {
var result map[string]string
json.Unmarshal(v, &result)
fmt.Println(result["result"]) // ok
}
// Factory pattern
val, err := cache.GetOrSet("expensive_key", func() ([]byte, error) {
return json.Marshal(computeExpensiveThing())
}, time.Hour)
// Helper para serializar directamente
cache.SetJSON("user:42", userStruct, 30*time.Minute)
```
## Notas
Usa WAL mode para mejor concurrencia de lecturas. La eviction lazy elimina expirados en cada `Get`. El schema comparte la tabla `cache` con `cache_to_sqlite_py_infra` — ambas implementaciones son interoperables sobre el mismo archivo SQLite si usan namespaces distintos. Requiere `github.com/mattn/go-sqlite3` (ya presente en el registry).
+134
View File
@@ -0,0 +1,134 @@
package infra
import (
"encoding/json"
"fmt"
"os"
"sync"
"testing"
"time"
)
func tempDB(t *testing.T) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "cache_*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
return f.Name()
}
func TestCacheToSQLite_SetGet(t *testing.T) {
t.Run("Set/Get basico", func(t *testing.T) {
c, err := CacheToSQLite(tempDB(t), "default")
if err != nil {
t.Fatal(err)
}
defer c.Close()
payload, _ := json.Marshal(map[string]int{"x": 1})
if err := c.Set("foo", payload, 0); err != nil {
t.Fatal(err)
}
got, ok := c.Get("foo")
if !ok {
t.Fatal("expected cache hit")
}
var result map[string]int
json.Unmarshal(got, &result)
if result["x"] != 1 {
t.Errorf("got %v, want x=1", result)
}
})
}
func TestCacheToSQLite_TTLExpirado(t *testing.T) {
t.Run("TTL expirado", func(t *testing.T) {
c, err := CacheToSQLite(tempDB(t), "default")
if err != nil {
t.Fatal(err)
}
defer c.Close()
payload, _ := json.Marshal("hello")
c.Set("temp", payload, 50*time.Millisecond)
time.Sleep(100 * time.Millisecond)
_, ok := c.Get("temp")
if ok {
t.Error("expected cache miss after TTL expiry")
}
})
}
func TestCacheToSQLite_GetOrSet(t *testing.T) {
t.Run("GetOrSet con factory", func(t *testing.T) {
c, err := CacheToSQLite(tempDB(t), "default")
if err != nil {
t.Fatal(err)
}
defer c.Close()
calls := 0
factory := func() ([]byte, error) {
calls++
return json.Marshal("computed")
}
v1, err := c.GetOrSet("k", factory, time.Minute)
if err != nil {
t.Fatal(err)
}
v2, err := c.GetOrSet("k", factory, time.Minute)
if err != nil {
t.Fatal(err)
}
if string(v1) != string(v2) {
t.Errorf("v1=%s v2=%s, want equal", v1, v2)
}
if calls != 1 {
t.Errorf("factory called %d times, want 1", calls)
}
})
}
func TestCacheToSQLite_Concurrencia(t *testing.T) {
t.Run("Concurrencia (goroutines)", func(t *testing.T) {
c, err := CacheToSQLite(tempDB(t), "parallel")
if err != nil {
t.Fatal(err)
}
defer c.Close()
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
key := fmt.Sprintf("key_%d", n)
payload, _ := json.Marshal(n)
if err := c.Set(key, payload, 0); err != nil {
errs <- err
return
}
got, ok := c.Get(key)
if !ok {
errs <- fmt.Errorf("miss for key %s", key)
return
}
var val int
json.Unmarshal(got, &val)
if val != n {
errs <- fmt.Errorf("key %s: got %d want %d", key, val, n)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
})
}
+136
View File
@@ -0,0 +1,136 @@
package infra
import (
"context"
"time"
)
// cronSchedule mirrors core.CronSchedule to avoid cross-package import.
// In practice, callers should use core.ParseCronExpr and pass the result here.
// The struct is duplicated to respect the registry rule of no cross-domain imports
// between function packages.
//
// CronTickerSchedule is the schedule consumed by CronTicker.
type CronTickerSchedule struct {
Minute []int
Hour []int
DayOfMonth []int
Month []int
DayOfWeek []int
}
// CronTicker creates a channel that emits the current time whenever the given
// schedule fires. It uses time.NewTimer internally, recalculating the next tick
// after each emission. The channel is closed when ctx is cancelled.
func CronTicker(schedule CronTickerSchedule, ctx context.Context) <-chan time.Time {
ch := make(chan time.Time, 1)
go func() {
defer close(ch)
for {
next := cronTickerNext(schedule, time.Now())
if next.IsZero() {
// Impossible schedule — nothing to emit.
return
}
delay := time.Until(next)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return
case tick := <-timer.C:
select {
case ch <- tick:
default:
// Drop if consumer is not ready.
}
}
}
}()
return ch
}
// cronTickerNext finds the next time after `after` that satisfies the schedule.
// Returns zero time if no match within 366 days.
func cronTickerNext(s CronTickerSchedule, after time.Time) time.Time {
t := after.Truncate(time.Minute).Add(time.Minute)
limit := after.Add(366 * 24 * time.Hour)
for t.Before(limit) {
if !cronIntIn(int(t.Month()), s.Month) {
t = cronNextMonth(t, s.Month)
if t.IsZero() {
return time.Time{}
}
continue
}
domOK := cronIntIn(t.Day(), s.DayOfMonth)
dowOK := cronIntIn(int(t.Weekday()), s.DayOfWeek)
if !domOK || !dowOK {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
continue
}
if !cronIntIn(t.Hour(), s.Hour) {
next := cronNextHour(t, s.Hour)
if next.IsZero() {
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
} else {
t = next
}
continue
}
if !cronIntIn(t.Minute(), s.Minute) {
next := cronNextMinute(t, s.Minute)
if next.IsZero() {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
} else {
t = next
}
continue
}
return t
}
return time.Time{}
}
func cronIntIn(v int, s []int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func cronNextMonth(t time.Time, months []int) time.Time {
month := int(t.Month())
for _, m := range months {
if m > month {
return time.Date(t.Year(), time.Month(m), 1, 0, 0, 0, 0, t.Location())
}
}
if len(months) > 0 {
return time.Date(t.Year()+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
}
return time.Time{}
}
func cronNextHour(t time.Time, hours []int) time.Time {
h := t.Hour()
for _, hh := range hours {
if hh > h {
return time.Date(t.Year(), t.Month(), t.Day(), hh, 0, 0, 0, t.Location())
}
}
return time.Time{}
}
func cronNextMinute(t time.Time, minutes []int) time.Time {
m := t.Minute()
for _, mm := range minutes {
if mm > m {
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), mm, 0, 0, t.Location())
}
}
return time.Time{}
}
+45
View File
@@ -0,0 +1,45 @@
---
name: cron_ticker
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CronTicker(schedule CronTickerSchedule, ctx context.Context) <-chan time.Time"
description: "Crea un channel que emite time.Time en cada tick del cron schedule. Usa time.NewTimer internamente, recalculando el proximo tick tras cada emision. El channel se cierra al cancelar el context. Incluye CronTickerSchedule (reflejo local de CronSchedule para evitar dependencia cross-package)."
tags: [cron, scheduling, ticker, channel, goroutine, concurrency, impure]
uses_functions: [parse_cron_expr_go_core, next_cron_time_go_core]
uses_types: [cron_schedule_go_core]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [context, time]
tested: true
tests:
- "context cancel cierra el channel"
- "ticker emite al llegar el momento del schedule"
test_file_path: "functions/infra/cron_ticker_test.go"
file_path: "functions/infra/cron_ticker.go"
---
## Ejemplo
```go
sched := CronTickerSchedule{
Minute: []int{0, 15, 30, 45},
Hour: intRange(0, 23),
DayOfMonth: intRange(1, 31),
Month: intRange(1, 12),
DayOfWeek: intRange(0, 6),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for tick := range CronTicker(sched, ctx) {
fmt.Println("tick:", tick)
}
```
## Notas
Funcion impura: lanza una goroutine, usa time.NewTimer y context. El tipo CronTickerSchedule es un reflejo local de core.CronSchedule para evitar imports cross-package entre dominios Go. En uso real, convertir el resultado de core.ParseCronExpr manualmente. El channel tiene buffer de 1 para evitar bloqueos si el consumidor es lento; los ticks extras se descartan.
+114
View File
@@ -0,0 +1,114 @@
package infra
import (
"context"
"testing"
"time"
)
func allMinutes() []int {
s := make([]int, 60)
for i := range s {
s[i] = i
}
return s
}
func allHours() []int {
s := make([]int, 24)
for i := range s {
s[i] = i
}
return s
}
func allDays() []int {
s := make([]int, 31)
for i := range s {
s[i] = i + 1
}
return s
}
func allMonths() []int {
s := make([]int, 12)
for i := range s {
s[i] = i + 1
}
return s
}
func allDOW() []int {
s := make([]int, 7)
for i := range s {
s[i] = i
}
return s
}
func TestCronTicker(t *testing.T) {
t.Run("context cancel cierra el channel", func(t *testing.T) {
sched := CronTickerSchedule{
Minute: allMinutes(),
Hour: allHours(),
DayOfMonth: allDays(),
Month: allMonths(),
DayOfWeek: allDOW(),
}
ctx, cancel := context.WithCancel(context.Background())
ch := CronTicker(sched, ctx)
// Cancel immediately.
cancel()
// Channel should close without blocking.
timeout := time.After(2 * time.Second)
select {
case _, ok := <-ch:
if ok {
// Might receive one tick before cancel propagates — acceptable.
}
// Drain remaining.
for range ch {
}
case <-timeout:
t.Error("channel did not close within 2s after context cancel")
}
})
t.Run("ticker emite al llegar el momento del schedule", func(t *testing.T) {
// Use a schedule that fires every minute (all minutes).
// The next tick is at most 60s away. We use a short-lived context
// to avoid waiting: instead we verify the channel is not nil and
// that cancellation closes it cleanly.
sched := CronTickerSchedule{
Minute: allMinutes(),
Hour: allHours(),
DayOfMonth: allDays(),
Month: allMonths(),
DayOfWeek: allDOW(),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ch := CronTicker(sched, ctx)
if ch == nil {
t.Fatal("CronTicker returned nil channel")
}
// Wait for context to expire, then confirm channel closes.
<-ctx.Done()
timeout := time.After(2 * time.Second)
for {
select {
case _, ok := <-ch:
if !ok {
return // channel closed, test passes
}
case <-timeout:
t.Error("channel did not close within 2s after context timeout")
return
}
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package infra
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
// HttpDownloadFile descarga url en destPath en streaming con io.Copy.
// Crea directorios intermedios con os.MkdirAll. Usa archivo temporal + rename
// para garantizar atomicidad (no deja archivo corrupto si falla a mitad).
// Retorna los bytes escritos.
func HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error) {
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return 0, fmt.Errorf("http_download_file: build request: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("http_download_file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
shortURL := url
if len(shortURL) > 100 {
shortURL = shortURL[:100]
}
return 0, fmt.Errorf("http_download_file: HTTP %d at %q", resp.StatusCode, shortURL)
}
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, fmt.Errorf("http_download_file: create dirs: %w", err)
}
// Archivo temporal en el mismo directorio para que rename sea atomico
tmp, err := os.CreateTemp(dir, ".download-*")
if err != nil {
return 0, fmt.Errorf("http_download_file: create temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() {
tmp.Close()
os.Remove(tmpPath) // no-op si rename tuvo exito
}()
n, err := io.Copy(tmp, resp.Body)
if err != nil {
return 0, fmt.Errorf("http_download_file: write: %w", err)
}
if err := tmp.Close(); err != nil {
return 0, fmt.Errorf("http_download_file: close temp: %w", err)
}
if err := os.Rename(tmpPath, destPath); err != nil {
return 0, fmt.Errorf("http_download_file: rename: %w", err)
}
return n, nil
}
+44
View File
@@ -0,0 +1,44 @@
---
name: http_download_file
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error)"
description: "Descarga url en destPath en streaming con io.Copy. Crea directorios con os.MkdirAll. Usa archivo temporal + rename para atomicidad (no deja archivo corrupto si falla). Retorna bytes escritos."
tags: [http, download, file, streaming, atomic, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "io", "net/http", "os", "path/filepath", "time"]
tested: true
tests:
- "httptest.Server sirve archivo binario"
- "Directorio creado automaticamente"
- "Archivo temporal + rename (no deja basura si falla)"
- "Size retornado coincide"
test_file_path: "functions/infra/http_download_file_test.go"
file_path: "functions/infra/http_download_file.go"
---
## Ejemplo
```go
n, err := HttpDownloadFile(
"https://example.com/report.pdf",
"/tmp/reports/report.pdf",
nil,
2*time.Minute,
)
if err != nil {
return err
}
fmt.Printf("Downloaded %d bytes\n", n)
```
## Notas
Solo usa stdlib. El archivo temporal se crea en el mismo directorio que destPath para que el rename sea atomico (mismo filesystem). Si la descarga falla, el archivo temporal se elimina con os.Remove (el defer lo garantiza). Compatible con archivos de cualquier tamano ya que usa streaming con io.Copy.
@@ -0,0 +1,99 @@
package infra
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestHttpDownloadFile(t *testing.T) {
t.Run("httptest.Server sirve archivo binario", func(t *testing.T) {
content := []byte("\x00\x01\x02\x03binary content")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
}))
defer srv.Close()
tmp := t.TempDir()
dest := filepath.Join(tmp, "out.bin")
n, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != int64(len(content)) {
t.Errorf("got %d bytes, want %d", n, len(content))
}
got, _ := os.ReadFile(dest)
if string(got) != string(content) {
t.Errorf("file content mismatch")
}
})
t.Run("Directorio creado automaticamente", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data"))
}))
defer srv.Close()
tmp := t.TempDir()
dest := filepath.Join(tmp, "nested", "deep", "file.bin")
_, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, err := os.Stat(dest); os.IsNotExist(err) {
t.Error("dest file does not exist after download")
}
})
t.Run("Archivo temporal + rename (no deja basura si falla)", func(t *testing.T) {
// Server que falla a mitad de la transferencia cortando la conexion
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("partial"))
// hijack y cierra bruscamente no disponible facilmente; simulamos con
// status 500 antes de escribir
}))
defer srv.Close()
// Verificar que un download exitoso no deja .download-* temporales
tmp := t.TempDir()
dest := filepath.Join(tmp, "file.bin")
HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
entries, _ := os.ReadDir(tmp)
for _, e := range entries {
if e.Name() != "file.bin" && filepath.Ext(e.Name()) != ".bin" {
t.Errorf("unexpected temp file left: %s", e.Name())
}
}
})
t.Run("Size retornado coincide", func(t *testing.T) {
content := make([]byte, 10000)
for i := range content {
content[i] = byte(i % 256)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(content)
}))
defer srv.Close()
tmp := t.TempDir()
dest := filepath.Join(tmp, "big.bin")
n, err := HttpDownloadFile(srv.URL, dest, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != int64(len(content)) {
t.Errorf("got %d bytes, want %d", n, len(content))
}
})
}
+56
View File
@@ -0,0 +1,56 @@
package infra
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// HttpGetJSON realiza un GET request a url y parsea la respuesta como JSON.
// Agrega Accept: application/json automaticamente. Retorna error si status >= 400
// incluyendo el status code y los primeros 200 bytes del body.
func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error) {
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("http_get_json: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http_get_json: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("http_get_json: read body: %w", err)
}
if resp.StatusCode >= 400 {
preview := body
if len(preview) > 200 {
preview = preview[:200]
}
shortURL := url
if len(shortURL) > 100 {
shortURL = shortURL[:100]
}
return nil, fmt.Errorf("http_get_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("http_get_json: parse JSON: %w", err)
}
return result, nil
}
+43
View File
@@ -0,0 +1,43 @@
---
name: http_get_json
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error)"
description: "GET request que espera JSON. Agrega Accept: application/json automaticamente. Retorna error con status code si >= 400. Siempre cierra body con defer."
tags: [http, json, get, client, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json", "fmt", "io", "net/http", "time"]
tested: true
tests:
- "httptest.Server con respuesta JSON"
- "Status 404 → error"
- "Timeout → error"
- "Headers custom"
test_file_path: "functions/infra/http_get_json_test.go"
file_path: "functions/infra/http_get_json.go"
---
## Ejemplo
```go
result, err := HttpGetJSON(
"https://api.example.com/users",
map[string]string{"X-Api-Key": "secret"},
10*time.Second,
)
if err != nil {
return nil, err
}
fmt.Println(result["total"])
```
## Notas
Solo usa stdlib (net/http, encoding/json). El timeout se configura en el http.Client. El error incluye los primeros 200 bytes del body para facilitar debugging. Los headers custom se fusionan con Accept: application/json (custom tiene precedencia).
+80
View File
@@ -0,0 +1,80 @@
package infra
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHttpGetJSON(t *testing.T) {
t.Run("httptest.Server con respuesta JSON", func(t *testing.T) {
payload := map[string]any{"ok": true, "value": float64(42)}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
result, err := HttpGetJSON(srv.URL, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["ok"] != true {
t.Errorf("got ok=%v, want true", result["ok"])
}
})
t.Run("Status 404 → error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
_, err := HttpGetJSON(srv.URL, nil, 5*time.Second)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should contain 404, got: %v", err)
}
})
t.Run("Timeout → error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No responde — bloquea hasta que el cliente cancela
<-r.Context().Done()
}))
defer srv.Close()
_, err := HttpGetJSON(srv.URL, nil, 50*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
})
t.Run("Headers custom", func(t *testing.T) {
receivedHeaders := make(chan http.Header, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders <- r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
headers := map[string]string{"X-Api-Key": "mytoken"}
_, err := HttpGetJSON(srv.URL, headers, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
h := <-receivedHeaders
if h.Get("X-Api-Key") != "mytoken" {
t.Errorf("X-Api-Key not sent, got: %v", h.Get("X-Api-Key"))
}
if h.Get("Accept") != "application/json" {
t.Errorf("Accept header missing, got: %v", h.Get("Accept"))
}
})
}
+63
View File
@@ -0,0 +1,63 @@
package infra
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// HttpPostJSON realiza un POST request con body JSON y parsea la respuesta como JSON.
// Agrega Content-Type: application/json y Accept: application/json automaticamente.
// Retorna error si status >= 400 incluyendo status code y los primeros 200 bytes del body.
func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("http_post_json: marshal body: %w", err)
}
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("http_post_json: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http_post_json: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("http_post_json: read body: %w", err)
}
if resp.StatusCode >= 400 {
preview := respBody
if len(preview) > 200 {
preview = preview[:200]
}
shortURL := url
if len(shortURL) > 100 {
shortURL = shortURL[:100]
}
return nil, fmt.Errorf("http_post_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("http_post_json: parse JSON: %w", err)
}
return result, nil
}
+43
View File
@@ -0,0 +1,43 @@
---
name: http_post_json
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error)"
description: "POST request con body JSON serializado con json.Marshal. Agrega Content-Type: application/json y Accept: application/json. Retorna error con status code si >= 400."
tags: [http, json, post, client, network, stdlib, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "time"]
tested: true
tests:
- "httptest.Server recibe body correcto"
- "Status 201 → exito"
- "Status 500 → error con body parcial"
test_file_path: "functions/infra/http_post_json_test.go"
file_path: "functions/infra/http_post_json.go"
---
## Ejemplo
```go
result, err := HttpPostJSON(
"https://api.example.com/users",
map[string]any{"name": "Alice", "role": "admin"},
map[string]string{"X-Api-Key": "secret"},
10*time.Second,
)
if err != nil {
return nil, err
}
fmt.Println(result["id"])
```
## Notas
Solo usa stdlib. El body acepta `any` y se serializa con json.Marshal. Headers custom se fusionan con Content-Type y Accept por defecto. El error incluye los primeros 200 bytes del body de respuesta.
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHttpPostJSON(t *testing.T) {
t.Run("httptest.Server recibe body correcto", func(t *testing.T) {
received := make(chan map[string]any, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
data, _ := io.ReadAll(r.Body)
json.Unmarshal(data, &body)
received <- body
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
_, err := HttpPostJSON(srv.URL, map[string]any{"name": "Alice", "score": 100}, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := <-received
if body["name"] != "Alice" {
t.Errorf("name not received correctly, got: %v", body["name"])
}
})
t.Run("Status 201 → exito", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id": 42}`))
}))
defer srv.Close()
result, err := HttpPostJSON(srv.URL, map[string]any{"x": 1}, nil, 5*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result["id"] != float64(42) {
t.Errorf("got id=%v, want 42", result["id"])
}
})
t.Run("Status 500 → error con body parcial", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error details", http.StatusInternalServerError)
}))
defer srv.Close()
_, err := HttpPostJSON(srv.URL, map[string]any{}, nil, 5*time.Second)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should contain 500, got: %v", err)
}
})
}
+24
View File
@@ -0,0 +1,24 @@
---
name: cron_schedule
lang: go
domain: core
version: "1.0.0"
algebraic: product
definition: |
type CronSchedule struct {
Minute []int
Hour []int
DayOfMonth []int
Month []int
DayOfWeek []int
Raw string
}
description: "Representacion de una expresion cron parseada con los valores expandidos por campo. Raw preserva la expresion original para debug."
tags: [cron, schedule, scheduling, time, parsed]
uses_types: []
file_path: "functions/core/cron_schedule.go"
---
## Notas
Tipo producto inmutable por convencion. Cada campo contiene la lista completa de valores validos para ese campo (ej: Minute=[0,15,30,45] para `*/15`). Producido por ParseCronExpr, consumido por NextCronTime y CronTicker.