Files
fn_registry/fn_operations/validate.go
T
egutierrez 67401cb967 feat: fn_operations library — entities, relations, types_snapshot con FTS y ciclos
Paquete Go completo con modelos (Entity, Relation, RelationInput, TypeSnapshot),
DB SQLite con WAL + FTS5 en entities, CRUD para las 4 tablas, validacion de
integridad, deteccion de ciclos solo en relaciones causales (via != ''), y
operaciones de alto nivel con snapshot automatico de tipos del registry.
9 tests, todos pasan.
2026-03-28 04:37:50 +01:00

182 lines
4.5 KiB
Go

package fn_operations
import (
"fmt"
"strings"
)
// ValidationError represents one or more integrity violations.
type ValidationError struct {
ID string
Errors []string
}
func (v *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.ID, strings.Join(v.Errors, "; "))
}
// ValidateEntity checks entity integrity rules.
func ValidateEntity(e *Entity) *ValidationError {
var errs []string
if e.ID == "" {
errs = append(errs, "id is required")
}
if e.Name == "" {
errs = append(errs, "name is required")
}
if e.TypeRef == "" {
errs = append(errs, "type_ref is required")
}
if e.Source == "" {
errs = append(errs, "source is required")
}
switch e.Status {
case StatusActive, StatusStale, StatusCorrupted, StatusArchived:
case "":
errs = append(errs, "status is required")
default:
errs = append(errs, fmt.Sprintf("invalid status: %s", e.Status))
}
if len(errs) > 0 {
return &ValidationError{ID: e.ID, Errors: errs}
}
return nil
}
// ValidateRelation checks relation integrity rules.
// knownEntities is a set of entity IDs that exist.
func ValidateRelation(r *Relation, knownEntities map[string]bool) *ValidationError {
var errs []string
if r.ID == "" {
errs = append(errs, "id is required")
}
if r.Name == "" {
errs = append(errs, "name is required")
}
if r.ToEntity == "" {
errs = append(errs, "to_entity is required")
}
// from_entity or relation_inputs — validated at operation level
if r.FromEntity != "" && r.ToEntity != "" && r.FromEntity == r.ToEntity {
errs = append(errs, "from_entity and to_entity cannot be the same")
}
if r.FromEntity != "" && !knownEntities[r.FromEntity] {
errs = append(errs, fmt.Sprintf("from_entity references unknown entity: %s", r.FromEntity))
}
if r.ToEntity != "" && !knownEntities[r.ToEntity] {
errs = append(errs, fmt.Sprintf("to_entity references unknown entity: %s", r.ToEntity))
}
if r.Weight != nil {
if *r.Weight < 0.0 || *r.Weight > 1.0 {
errs = append(errs, "weight must be between 0.0 and 1.0")
}
}
if r.StartedAt != nil && r.EndedAt != nil {
if r.StartedAt.After(*r.EndedAt) {
errs = append(errs, "started_at must be before ended_at")
}
}
switch r.Direction {
case DirUnidirectional, DirBidirectional, DirInverse, "":
default:
errs = append(errs, fmt.Sprintf("invalid direction: %s", r.Direction))
}
if len(errs) > 0 {
return &ValidationError{ID: r.ID, Errors: errs}
}
return nil
}
// ValidateRelationInputs checks relation_inputs integrity.
func ValidateRelationInputs(inputs []RelationInput, knownEntities map[string]bool) *ValidationError {
var errs []string
if len(inputs) < 2 {
errs = append(errs, "relation_inputs must have at least 2 entries")
}
for i, ri := range inputs {
if ri.RelationID == "" {
errs = append(errs, fmt.Sprintf("input[%d]: relation_id is required", i))
}
if ri.EntityID == "" {
errs = append(errs, fmt.Sprintf("input[%d]: entity_id is required", i))
}
if ri.Role == "" {
errs = append(errs, fmt.Sprintf("input[%d]: role is required", i))
}
if ri.EntityID != "" && !knownEntities[ri.EntityID] {
errs = append(errs, fmt.Sprintf("input[%d]: entity_id references unknown entity: %s", i, ri.EntityID))
}
}
if len(errs) > 0 {
id := "relation_inputs"
if len(inputs) > 0 {
id = inputs[0].RelationID
}
return &ValidationError{ID: id, Errors: errs}
}
return nil
}
// DetectCycle checks if adding a causal relation (from -> to) creates a cycle.
// Only considers relations where via != "" (causal/transformational).
// Semantic relations (via == "") are exempt from cycle detection.
func DetectCycle(db *DB, fromEntity, toEntity string) error {
if fromEntity == "" || toEntity == "" {
return nil
}
// BFS from toEntity following only causal relations.
// If we reach fromEntity, there's a cycle.
visited := map[string]bool{}
queue := []string{toEntity}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if visited[current] {
continue
}
visited[current] = true
if current == fromEntity {
return fmt.Errorf("cycle detected: adding relation %s -> %s would create a causal cycle", fromEntity, toEntity)
}
// Follow causal relations from current entity
rows, err := db.conn.Query(`
SELECT to_entity FROM relations
WHERE from_entity = ? AND via != ''`, current)
if err != nil {
return fmt.Errorf("querying relations for cycle detection: %w", err)
}
for rows.Next() {
var next string
if err := rows.Scan(&next); err != nil {
rows.Close()
return err
}
if !visited[next] {
queue = append(queue, next)
}
}
rows.Close()
}
return nil
}