67401cb967
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.
182 lines
4.5 KiB
Go
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
|
|
}
|