feat: executions, assertions y bucle reactivo en fn_operations
Añade Execution, Assertion, AssertionResult al paquete fn_operations. Motor de evaluación de assertions con reescritura SQL automática. Bucle reactivo: ExecuteAndReact evalúa assertions y cambia status de entities (corrupted/stale) + auto-crea proposals en registry. CLI fn ops: assertion (add/list/show/delete/eval) y execution (add/list/show). Migración 002_executions_assertions.sql con FTS para assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package fn_operations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fieldPattern matches bare field names (word chars) that are NOT already inside
|
||||
// a json_extract call, a SQL keyword, a string literal, or a number.
|
||||
var fieldPattern = regexp.MustCompile(`\b([a-zA-Z_][a-zA-Z0-9_]*)\b`)
|
||||
|
||||
// sqlKeywords that should not be rewritten to json_extract.
|
||||
var sqlKeywords = map[string]bool{
|
||||
"AND": true, "OR": true, "NOT": true, "IS": true, "NULL": true,
|
||||
"IN": true, "BETWEEN": true, "LIKE": true, "GLOB": true,
|
||||
"TRUE": true, "FALSE": true, "CASE": true, "WHEN": true,
|
||||
"THEN": true, "ELSE": true, "END": true, "SELECT": true,
|
||||
"FROM": true, "WHERE": true, "AS": true, "CAST": true,
|
||||
}
|
||||
|
||||
// sqlFunctions that should not be rewritten.
|
||||
var sqlFunctions = map[string]bool{
|
||||
"json_extract": true, "datetime": true, "now": true,
|
||||
"abs": true, "avg": true, "count": true, "max": true, "min": true,
|
||||
"sum": true, "total": true, "length": true, "typeof": true,
|
||||
"coalesce": true, "ifnull": true, "nullif": true,
|
||||
"upper": true, "lower": true, "trim": true, "replace": true,
|
||||
"substr": true, "instr": true, "round": true,
|
||||
}
|
||||
|
||||
// rewriteRule transforms a rule expression into SQL that operates on entity metadata.
|
||||
// Bare field names are rewritten to json_extract(metadata, '$.field').
|
||||
// If the rule already uses json_extract, it is left as-is.
|
||||
func rewriteRule(rule string) string {
|
||||
if strings.Contains(rule, "json_extract") {
|
||||
return rule
|
||||
}
|
||||
|
||||
return fieldPattern.ReplaceAllStringFunc(rule, func(match string) string {
|
||||
upper := strings.ToUpper(match)
|
||||
if sqlKeywords[upper] {
|
||||
return match
|
||||
}
|
||||
if sqlFunctions[strings.ToLower(match)] {
|
||||
return match
|
||||
}
|
||||
// Skip numeric-looking tokens (shouldn't match the regex, but be safe)
|
||||
if match[0] >= '0' && match[0] <= '9' {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf("json_extract(metadata, '$.%s')", match)
|
||||
})
|
||||
}
|
||||
|
||||
// EvalAssertion evaluates a single assertion against its entity's metadata.
|
||||
// Returns a result with pass/fail/skip status.
|
||||
func EvalAssertion(db *DB, a *Assertion, executionID string) (*AssertionResult, error) {
|
||||
result := &AssertionResult{
|
||||
ID: fmt.Sprintf("ar_%s_%d", a.ID, time.Now().UnixNano()),
|
||||
AssertionID: a.ID,
|
||||
ExecutionID: executionID,
|
||||
EvaluatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
rewritten := rewriteRule(a.Rule)
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT CASE WHEN (%s) THEN 'pass' ELSE 'fail' END,
|
||||
metadata
|
||||
FROM entities WHERE id = ?`, rewritten)
|
||||
|
||||
var status, metadataJSON string
|
||||
err := db.conn.QueryRow(q, a.EntityID).Scan(&status, &metadataJSON)
|
||||
if err != nil {
|
||||
result.Status = ResultSkip
|
||||
result.Message = fmt.Sprintf("evaluation error: %v", err)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if status == "pass" {
|
||||
result.Status = ResultPass
|
||||
} else {
|
||||
result.Status = ResultFail
|
||||
result.Value = unmarshalJSON(metadataJSON)
|
||||
result.Message = fmt.Sprintf("rule failed: %s", a.Rule)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EvalEntityAssertions evaluates all active assertions for an entity.
|
||||
// Returns results and persists them in the database.
|
||||
func EvalEntityAssertions(db *DB, entityID, executionID string) ([]AssertionResult, error) {
|
||||
active := true
|
||||
assertions, err := db.ListAssertions(entityID, &active)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing assertions for entity %s: %w", entityID, err)
|
||||
}
|
||||
|
||||
var results []AssertionResult
|
||||
for _, a := range assertions {
|
||||
ar, err := EvalAssertion(db, &a, executionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluating assertion %s: %w", a.ID, err)
|
||||
}
|
||||
|
||||
if err := db.InsertAssertionResult(ar); err != nil {
|
||||
return nil, fmt.Errorf("storing result for assertion %s: %w", a.ID, err)
|
||||
}
|
||||
|
||||
results = append(results, *ar)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user