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.
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
package fn_operations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tempDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("opening test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestOpenAndClose(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Fatal("db file should exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeSnapshotCRUD(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
ts := &TypeSnapshot{
|
||||
ID: "ohlcv_go_finance",
|
||||
Version: "1.0.0",
|
||||
Lang: "go",
|
||||
Algebraic: "product",
|
||||
Definition: "type OHLCV struct { ... }",
|
||||
Description: "Vela de mercado",
|
||||
}
|
||||
|
||||
if err := db.InsertTypeSnapshot(ts); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetTypeSnapshot("ohlcv_go_finance")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected snapshot, got nil")
|
||||
}
|
||||
if got.Definition != ts.Definition {
|
||||
t.Errorf("definition = %q, want %q", got.Definition, ts.Definition)
|
||||
}
|
||||
|
||||
// INSERT OR IGNORE: second insert should not overwrite
|
||||
ts2 := &TypeSnapshot{
|
||||
ID: "ohlcv_go_finance",
|
||||
Version: "2.0.0",
|
||||
Lang: "go",
|
||||
Algebraic: "product",
|
||||
Definition: "type OHLCV struct { CHANGED }",
|
||||
Description: "Changed",
|
||||
}
|
||||
if err := db.InsertTypeSnapshot(ts2); err != nil {
|
||||
t.Fatalf("insert duplicate: %v", err)
|
||||
}
|
||||
got2, _ := db.GetTypeSnapshot("ohlcv_go_finance")
|
||||
if got2.Version != "1.0.0" {
|
||||
t.Errorf("snapshot should be immutable, got version %q", got2.Version)
|
||||
}
|
||||
|
||||
// Not found
|
||||
missing, err := db.GetTypeSnapshot("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing: %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Error("expected nil for missing snapshot")
|
||||
}
|
||||
|
||||
all, err := db.ListTypeSnapshots()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Errorf("expected 1 snapshot, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityCRUD(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
// Insert snapshot first (type_ref)
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{
|
||||
ID: "tick_go_finance", Version: "1.0.0", Lang: "go", Algebraic: "product",
|
||||
})
|
||||
|
||||
e := &Entity{
|
||||
ID: "ticks_btcusdt_2024",
|
||||
Name: "ticks_btcusdt_2024",
|
||||
TypeRef: "tick_go_finance",
|
||||
Status: StatusActive,
|
||||
Source: "binance_api",
|
||||
Domain: "market_data",
|
||||
Tags: []string{"btc", "binance"},
|
||||
Metadata: map[string]any{
|
||||
"pair": "BTCUSDT",
|
||||
"exchange": "binance",
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.InsertEntity(e); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetEntity("ticks_btcusdt_2024")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected entity, got nil")
|
||||
}
|
||||
if got.Source != "binance_api" {
|
||||
t.Errorf("source = %q, want binance_api", got.Source)
|
||||
}
|
||||
if len(got.Tags) != 2 {
|
||||
t.Errorf("tags len = %d, want 2", len(got.Tags))
|
||||
}
|
||||
if got.Metadata["pair"] != "BTCUSDT" {
|
||||
t.Errorf("metadata pair = %v, want BTCUSDT", got.Metadata["pair"])
|
||||
}
|
||||
|
||||
// Update
|
||||
got.Status = StatusStale
|
||||
if err := db.UpdateEntity(got); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
updated, _ := db.GetEntity("ticks_btcusdt_2024")
|
||||
if updated.Status != StatusStale {
|
||||
t.Errorf("status = %q, want stale", updated.Status)
|
||||
}
|
||||
|
||||
// List
|
||||
all, err := db.ListEntities("market_data", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(all))
|
||||
}
|
||||
|
||||
// Search
|
||||
found, err := db.SearchEntities("btcusdt", "")
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Errorf("search expected 1, got %d", len(found))
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := db.DeleteEntity("ticks_btcusdt_2024"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
deleted, _ := db.GetEntity("ticks_btcusdt_2024")
|
||||
if deleted != nil {
|
||||
t.Error("expected nil after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationCRUD(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
// Setup entities
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
|
||||
r := &Relation{
|
||||
ID: "a__to__b__via__transform",
|
||||
Name: "TRANSFORMA",
|
||||
FromEntity: "a",
|
||||
ToEntity: "b",
|
||||
Direction: DirUnidirectional,
|
||||
Status: RelDesigned,
|
||||
}
|
||||
|
||||
if err := InsertRelationSafe(db, r); err != nil {
|
||||
t.Fatalf("insert relation: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetRelation("a__to__b__via__transform")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected relation, got nil")
|
||||
}
|
||||
if got.Name != "TRANSFORMA" {
|
||||
t.Errorf("name = %q, want TRANSFORMA", got.Name)
|
||||
}
|
||||
|
||||
// List by entity
|
||||
rels, err := db.ListRelations("a")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rels) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(rels))
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := db.DeleteRelation("a__to__b__via__transform"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
deleted, _ := db.GetRelation("a__to__b__via__transform")
|
||||
if deleted != nil {
|
||||
t.Error("expected nil after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationInputs(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
|
||||
r := &Relation{
|
||||
ID: "multi__to__c",
|
||||
Name: "ENRIQUECE",
|
||||
ToEntity: "c",
|
||||
Direction: DirUnidirectional,
|
||||
Status: RelDesigned,
|
||||
}
|
||||
|
||||
inputs := []RelationInput{
|
||||
{ID: "i1", RelationID: "multi__to__c", EntityID: "a", Role: "base"},
|
||||
{ID: "i2", RelationID: "multi__to__c", EntityID: "b", Role: "lookup"},
|
||||
}
|
||||
|
||||
if err := InsertRelationWithInputs(db, r, inputs); err != nil {
|
||||
t.Fatalf("insert with inputs: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetRelationInputs("multi__to__c")
|
||||
if err != nil {
|
||||
t.Fatalf("get inputs: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("expected 2 inputs, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetectionCausal(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "c", Name: "c", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
|
||||
// a -> b (causal)
|
||||
InsertRelationSafe(db, &Relation{
|
||||
ID: "ab", Name: "T1", FromEntity: "a", ToEntity: "b", Via: "fn1",
|
||||
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
|
||||
})
|
||||
|
||||
// b -> c (causal)
|
||||
InsertRelationSafe(db, &Relation{
|
||||
ID: "bc", Name: "T2", FromEntity: "b", ToEntity: "c", Via: "fn2",
|
||||
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
|
||||
})
|
||||
|
||||
// c -> a (causal) should fail — creates cycle
|
||||
err := InsertRelationSafe(db, &Relation{
|
||||
ID: "ca", Name: "T3", FromEntity: "c", ToEntity: "a", Via: "fn3",
|
||||
Purity: "impure", Direction: DirUnidirectional, Status: RelDesigned,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetectionSemanticAllowed(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||
db.InsertEntity(&Entity{ID: "juan", Name: "juan", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "paula", Name: "paula", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
|
||||
// juan -> paula (semantic, no via)
|
||||
if err := InsertRelationSafe(db, &Relation{
|
||||
ID: "jp", Name: "CONOCE A", FromEntity: "juan", ToEntity: "paula",
|
||||
Direction: DirBidirectional, Status: RelRunning,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert semantic: %v", err)
|
||||
}
|
||||
|
||||
// paula -> juan (semantic, no via) — should succeed, no cycle check
|
||||
if err := InsertRelationSafe(db, &Relation{
|
||||
ID: "pj", Name: "CONOCE A", FromEntity: "paula", ToEntity: "juan",
|
||||
Direction: DirBidirectional, Status: RelRunning,
|
||||
}); err != nil {
|
||||
t.Fatalf("semantic cycle should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entity Entity
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive, Source: "test"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
entity: Entity{ID: "x", TypeRef: "t1", Status: StatusActive, Source: "test"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing source",
|
||||
entity: Entity{ID: "x", Name: "x", TypeRef: "t1", Status: StatusActive},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing type_ref",
|
||||
entity: Entity{ID: "x", Name: "x", Status: StatusActive, Source: "test"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateEntity(&tt.entity)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateEntity() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEntityGraph(t *testing.T) {
|
||||
db := tempDB(t)
|
||||
|
||||
db.InsertTypeSnapshot(&TypeSnapshot{ID: "t1", Version: "1.0.0", Lang: "go", Algebraic: "product"})
|
||||
db.InsertEntity(&Entity{ID: "a", Name: "a", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
db.InsertEntity(&Entity{ID: "b", Name: "b", TypeRef: "t1", Status: StatusActive, Source: "test"})
|
||||
InsertRelationSafe(db, &Relation{
|
||||
ID: "ab", Name: "FLUYE", FromEntity: "a", ToEntity: "b",
|
||||
Direction: DirUnidirectional, Status: RelDesigned,
|
||||
})
|
||||
|
||||
g, err := GetEntityGraph(db)
|
||||
if err != nil {
|
||||
t.Fatalf("graph: %v", err)
|
||||
}
|
||||
if len(g.Entities) != 2 {
|
||||
t.Errorf("entities = %d, want 2", len(g.Entities))
|
||||
}
|
||||
if len(g.Relations) != 1 {
|
||||
t.Errorf("relations = %d, want 1", len(g.Relations))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user