// Copyright (c) 2025 YAMS Contributors // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include "../../../common/metadata_test_db.h" #include #include using namespace yams::app::services; using namespace yams::metadata; namespace { struct GraphQueryServiceFixture { GraphQueryServiceFixture() { dbPath = yams::test::migrated_metadata_db_template().clone("yams_graph_query_service_db_"); testDir = dbPath.parent_path() / dbPath.stem(); std::filesystem::create_directories(testDir); ConnectionPoolConfig poolConfig; poolConfig.minConnections = 1; poolConfig.maxConnections = 2; pool = std::make_shared(dbPath.string(), poolConfig); auto poolInitRes = pool->initialize(); REQUIRE((poolInitRes.has_value())); metadataRepo = std::make_shared( *pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady); KnowledgeGraphStoreConfig kgConfig; kgConfig.enable_alias_fts = true; kgConfig.enable_wal = false; auto kgResult = makeSqliteKnowledgeGraphStore(*pool, kgConfig); REQUIRE((kgResult.has_value())); kgStore = std::shared_ptr(std::move(kgResult).value()); metadataRepo->setKnowledgeGraphStore(kgStore); } ~GraphQueryServiceFixture() { kgStore.reset(); metadataRepo.reset(); pool.reset(); std::error_code ec; std::filesystem::remove_all(testDir, ec); yams::test::remove_sqlite_artifacts(dbPath); } std::filesystem::path testDir; std::filesystem::path dbPath; std::shared_ptr pool; std::shared_ptr metadataRepo; std::shared_ptr kgStore; }; } // namespace TEST_CASE("GraphQueryService: relation name filters apply", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode caller; caller.nodeKey = "symbol:caller"; caller.label = "caller"; caller.type = "function"; auto callerId = fixture.kgStore->upsertNode(caller); REQUIRE((callerId.has_value())); KGNode callee; callee.nodeKey = "symbol:callee"; callee.label = "callee"; callee.type = "function"; auto calleeId = fixture.kgStore->upsertNode(callee); REQUIRE((calleeId.has_value())); KGNode includeTarget; includeTarget.nodeKey = "path:file:/include/header.h"; includeTarget.label = "header.h"; includeTarget.type = "file"; auto includeId = fixture.kgStore->upsertNode(includeTarget); REQUIRE((includeId.has_value())); KGEdge callEdge; callEdge.srcNodeId = callerId.value(); callEdge.dstNodeId = calleeId.value(); callEdge.relation = "calls"; callEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(callEdge).has_value())); KGEdge includeEdge; includeEdge.srcNodeId = callerId.value(); includeEdge.dstNodeId = includeId.value(); includeEdge.relation = "includes"; includeEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(includeEdge).has_value())); GraphQueryRequest req; req.nodeId = callerId.value(); req.maxDepth = 1; req.maxResults = 10; req.relationNames = {"calls"}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); REQUIRE((resp.allConnectedNodes[0].nodeMetadata.node.nodeKey == "symbol:callee")); } TEST_CASE("GraphQueryService: PathVersion includes has_version edges", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode pathNode; pathNode.nodeKey = "path:abc:/repo/file.cpp"; pathNode.label = "/repo/file.cpp"; pathNode.type = "path"; auto pathId = fixture.kgStore->upsertNode(pathNode); REQUIRE((pathId.has_value())); KGNode blobNode; blobNode.nodeKey = "blob:deadbeef"; blobNode.label = "deadbeef"; blobNode.type = "blob"; auto blobId = fixture.kgStore->upsertNode(blobNode); REQUIRE((blobId.has_value())); KGEdge versionEdge; versionEdge.srcNodeId = pathId.value(); versionEdge.dstNodeId = blobId.value(); versionEdge.relation = "has_version"; versionEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(versionEdge).has_value())); GraphQueryRequest req; req.nodeId = pathId.value(); req.maxDepth = 1; req.maxResults = 10; req.relationFilters = {GraphRelationType::PathVersion}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); REQUIRE((resp.allConnectedNodes[0].nodeMetadata.node.nodeKey == "blob:deadbeef")); } TEST_CASE("GraphQueryService: RenamedFrom includes renamed_to edges", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode oldPath; oldPath.nodeKey = "path:snap1:/repo/old.cpp"; oldPath.label = "/repo/old.cpp"; oldPath.type = "path"; auto oldId = fixture.kgStore->upsertNode(oldPath); REQUIRE((oldId.has_value())); KGNode newPath; newPath.nodeKey = "path:snap2:/repo/new.cpp"; newPath.label = "/repo/new.cpp"; newPath.type = "path"; auto newId = fixture.kgStore->upsertNode(newPath); REQUIRE((newId.has_value())); KGEdge renameEdge; renameEdge.srcNodeId = oldId.value(); renameEdge.dstNodeId = newId.value(); renameEdge.relation = "renamed_to"; renameEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(renameEdge).has_value())); GraphQueryRequest req; req.nodeId = oldId.value(); req.maxDepth = 1; req.maxResults = 10; req.relationFilters = {GraphRelationType::RenamedFrom}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); REQUIRE((resp.allConnectedNodes[0].nodeMetadata.node.nodeKey == "path:snap2:/repo/new.cpp")); } TEST_CASE("GraphQueryService: relation names are canonicalized", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode caller; caller.nodeKey = "symbol:canonical:caller"; caller.label = "caller"; caller.type = "function"; auto callerId = fixture.kgStore->upsertNode(caller); REQUIRE((callerId.has_value())); KGNode callee; callee.nodeKey = "symbol:canonical:callee"; callee.label = "callee"; callee.type = "function"; auto calleeId = fixture.kgStore->upsertNode(callee); REQUIRE((calleeId.has_value())); KGEdge callEdge; callEdge.srcNodeId = callerId.value(); callEdge.dstNodeId = calleeId.value(); callEdge.relation = "calls"; callEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(callEdge).has_value())); GraphQueryRequest req; req.nodeId = callerId.value(); req.maxDepth = 1; req.maxResults = 10; req.relationNames = {"CALL"}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); REQUIRE((resp.allConnectedNodes[0].nodeMetadata.node.nodeKey == "symbol:canonical:callee")); } TEST_CASE("GraphQueryService: SymbolReference enum includes calls", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode caller; caller.nodeKey = "symbol:enum:caller"; caller.label = "caller"; caller.type = "function"; auto callerId = fixture.kgStore->upsertNode(caller); REQUIRE((callerId.has_value())); KGNode callee; callee.nodeKey = "symbol:enum:callee"; callee.label = "callee"; callee.type = "function"; auto calleeId = fixture.kgStore->upsertNode(callee); REQUIRE((calleeId.has_value())); KGEdge callEdge; callEdge.srcNodeId = callerId.value(); callEdge.dstNodeId = calleeId.value(); callEdge.relation = "calls"; callEdge.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(callEdge).has_value())); GraphQueryRequest req; req.nodeId = callerId.value(); req.maxDepth = 1; req.maxResults = 10; req.relationFilters = {GraphRelationType::SymbolReference}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); REQUIRE((resp.allConnectedNodes[0].nodeMetadata.node.nodeKey == "symbol:enum:callee")); } TEST_CASE("GraphQueryService: BFS uses discovered parent edge for attribution", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode origin; origin.nodeKey = "symbol:path:origin"; origin.label = "origin"; origin.type = "function"; auto originId = fixture.kgStore->upsertNode(origin); REQUIRE((originId.has_value())); KGNode mid; mid.nodeKey = "symbol:path:mid"; mid.label = "mid"; mid.type = "function"; auto midId = fixture.kgStore->upsertNode(mid); REQUIRE((midId.has_value())); KGNode target; target.nodeKey = "symbol:path:target"; target.label = "target"; target.type = "function"; auto targetId = fixture.kgStore->upsertNode(target); REQUIRE((targetId.has_value())); KGEdge edge1; edge1.srcNodeId = originId.value(); edge1.dstNodeId = midId.value(); edge1.relation = "contains"; edge1.weight = 1.0f; REQUIRE((fixture.kgStore->addEdge(edge1).has_value())); KGEdge edge2; edge2.srcNodeId = midId.value(); edge2.dstNodeId = targetId.value(); edge2.relation = "calls"; edge2.weight = 0.9f; edge2.properties = R"({"source":"unit","confidence":0.73})"; REQUIRE((fixture.kgStore->addEdge(edge2).has_value())); GraphQueryRequest req; req.nodeId = originId.value(); req.maxDepth = 2; req.maxResults = 10; req.hydrateFully = false; req.includeEdgeProperties = true; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 2)); const auto* targetNode = [&]() -> const GraphConnectedNode* { for (const auto& node : resp.allConnectedNodes) { if (node.nodeMetadata.node.nodeKey == "symbol:path:target") { return &node; } } return nullptr; }(); REQUIRE((targetNode != nullptr)); CHECK((targetNode->distance == 2)); REQUIRE((targetNode->connectingEdges.size() == 1)); CHECK((targetNode->connectingEdges[0].srcNodeId == midId.value())); CHECK((targetNode->connectingEdges[0].dstNodeId == targetId.value())); CHECK((targetNode->connectingEdges[0].relation == "calls")); REQUIRE((targetNode->connectingEdges[0].properties.has_value())); CHECK((targetNode->connectingEdges[0].properties.value().find("\"confidence\":0.73") != std::string::npos)); } TEST_CASE("GraphQueryService: multi-path chooses higher-signal parent relation", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode origin{.nodeKey = "symbol:multi:origin", .label = "origin", .type = "function"}; KGNode structHop{.nodeKey = "symbol:multi:struct", .label = "structHop", .type = "file"}; KGNode semanticHop{ .nodeKey = "symbol:multi:semantic", .label = "semanticHop", .type = "function"}; KGNode target{.nodeKey = "symbol:multi:target", .label = "target", .type = "function"}; auto originId = fixture.kgStore->upsertNode(origin); auto structHopId = fixture.kgStore->upsertNode(structHop); auto semanticHopId = fixture.kgStore->upsertNode(semanticHop); auto targetId = fixture.kgStore->upsertNode(target); REQUIRE((originId.has_value())); REQUIRE((structHopId.has_value())); REQUIRE((semanticHopId.has_value())); REQUIRE((targetId.has_value())); KGEdge e1{.srcNodeId = originId.value(), .dstNodeId = structHopId.value(), .relation = "contains", .weight = 1.0f}; KGEdge e2{.srcNodeId = originId.value(), .dstNodeId = semanticHopId.value(), .relation = "calls", .weight = 1.0f}; KGEdge e3{.srcNodeId = structHopId.value(), .dstNodeId = targetId.value(), .relation = "contains", .weight = 1.0f}; KGEdge e4{.srcNodeId = semanticHopId.value(), .dstNodeId = targetId.value(), .relation = "calls", .weight = 1.0f}; REQUIRE((fixture.kgStore->addEdge(e1).has_value())); REQUIRE((fixture.kgStore->addEdge(e2).has_value())); REQUIRE((fixture.kgStore->addEdge(e3).has_value())); REQUIRE((fixture.kgStore->addEdge(e4).has_value())); GraphQueryRequest req; req.nodeId = originId.value(); req.maxDepth = 2; req.maxResults = 10; req.hydrateFully = false; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); const auto* targetNode = [&]() -> const GraphConnectedNode* { for (const auto& node : resp.allConnectedNodes) { if (node.nodeMetadata.node.nodeKey == "symbol:multi:target") { return &node; } } return nullptr; }(); REQUIRE((targetNode != nullptr)); REQUIRE((targetNode->connectingEdges.size() == 1)); CHECK((targetNode->connectingEdges[0].srcNodeId == semanticHopId.value())); CHECK((targetNode->connectingEdges[0].relation == "calls")); } TEST_CASE("GraphQueryService: cycles keep path attribution stable", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode origin{.nodeKey = "symbol:cycle:origin", .label = "origin", .type = "function"}; KGNode mid{.nodeKey = "symbol:cycle:mid", .label = "mid", .type = "function"}; KGNode target{.nodeKey = "symbol:cycle:target", .label = "target", .type = "function"}; auto originId = fixture.kgStore->upsertNode(origin); auto midId = fixture.kgStore->upsertNode(mid); auto targetId = fixture.kgStore->upsertNode(target); REQUIRE((originId.has_value())); REQUIRE((midId.has_value())); REQUIRE((targetId.has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = originId.value(), .dstNodeId = midId.value(), .relation = "calls", .weight = 1.0f}) .has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = midId.value(), .dstNodeId = originId.value(), .relation = "calls", .weight = 1.0f}) .has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = midId.value(), .dstNodeId = targetId.value(), .relation = "references", .weight = 1.0f}) .has_value())); GraphQueryRequest req; req.nodeId = originId.value(); req.maxDepth = 2; req.maxResults = 10; req.hydrateFully = false; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 2)); const auto& depthOne = resp.nodesByDistance.at(1); REQUIRE((depthOne.size() == 1)); CHECK((depthOne.front().nodeMetadata.node.nodeKey == "symbol:cycle:mid")); const auto& depthTwo = resp.nodesByDistance.at(2); REQUIRE((depthTwo.size() == 1)); CHECK((depthTwo.front().nodeMetadata.node.nodeKey == "symbol:cycle:target")); REQUIRE((depthTwo.front().connectingEdges.size() == 1)); CHECK((depthTwo.front().connectingEdges.front().srcNodeId == midId.value())); CHECK((depthTwo.front().connectingEdges.front().relation == "references")); } TEST_CASE("GraphQueryService: relation filters apply before traversal expansion", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode origin{.nodeKey = "symbol:filter:path:origin", .label = "origin", .type = "function"}; KGNode mid{.nodeKey = "symbol:filter:path:mid", .label = "mid", .type = "file"}; KGNode target{.nodeKey = "symbol:filter:path:target", .label = "target", .type = "function"}; auto originId = fixture.kgStore->upsertNode(origin); auto midId = fixture.kgStore->upsertNode(mid); auto targetId = fixture.kgStore->upsertNode(target); REQUIRE((originId.has_value())); REQUIRE((midId.has_value())); REQUIRE((targetId.has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = originId.value(), .dstNodeId = midId.value(), .relation = "contains", .weight = 1.0f}) .has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = midId.value(), .dstNodeId = targetId.value(), .relation = "calls", .weight = 1.0f}) .has_value())); GraphQueryRequest req; req.nodeId = originId.value(); req.maxDepth = 2; req.maxResults = 10; req.hydrateFully = false; req.relationNames = {"contains"}; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 1)); CHECK((resp.allConnectedNodes.front().nodeMetadata.node.nodeKey == "symbol:filter:path:mid")); } TEST_CASE("GraphQueryService: semantic relations rank above structural at same depth", "[services][graph]") { GraphQueryServiceFixture fixture; auto service = makeGraphQueryService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); KGNode origin{.nodeKey = "symbol:rank:origin", .label = "origin", .type = "function"}; KGNode semantic{.nodeKey = "symbol:rank:semantic", .label = "semantic", .type = "function"}; KGNode structural{.nodeKey = "symbol:rank:structural", .label = "structural", .type = "file"}; auto originId = fixture.kgStore->upsertNode(origin); auto semanticId = fixture.kgStore->upsertNode(semantic); auto structuralId = fixture.kgStore->upsertNode(structural); REQUIRE((originId.has_value())); REQUIRE((semanticId.has_value())); REQUIRE((structuralId.has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = originId.value(), .dstNodeId = structuralId.value(), .relation = "contains", .weight = 1.0f}) .has_value())); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = originId.value(), .dstNodeId = semanticId.value(), .relation = "calls", .weight = 1.0f}) .has_value())); GraphQueryRequest req; req.nodeId = originId.value(); req.maxDepth = 1; req.maxResults = 10; req.hydrateFully = false; auto result = service->query(req); REQUIRE((result.has_value())); const auto& resp = result.value(); REQUIRE((resp.allConnectedNodes.size() == 2)); CHECK((resp.allConnectedNodes.front().nodeMetadata.node.nodeKey == "symbol:rank:semantic")); CHECK((resp.allConnectedNodes.back().nodeMetadata.node.nodeKey == "symbol:rank:structural")); }