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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user