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 51d31a67d1
commit 53200cbc0d
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)
}
})
}