diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java index 44b4a83a..d2923579 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java @@ -307,6 +307,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.layer)"); tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.module)"); tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.filePath)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)"); tx.commit(); } CliOutput.info(" Created Neo4j indexes"); diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java index de843aa0..0528bdcf 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java @@ -23,7 +23,7 @@ public interface GraphRepository extends Neo4jRepository { @Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n") List findByFilePath(String filePath); - @Query("MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit") + @Query("MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit") List search(String text, int limit); @Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n") diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index 3e579d5c..564d4c8e 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -84,9 +84,11 @@ public void bulkSave(List nodes) { } } while (deleted > 0); - // 2. Create index on id property for fast MATCH during edge creation + // 2. Create indexes: id for MATCH, label_lower/fqn_lower for fast case-insensitive search try (Transaction tx = graphDb.beginTx()) { tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.id)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)"); + tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)"); tx.commit(); } @@ -182,6 +184,9 @@ private Map nodeToProps(CodeNode node) { if (node.getAnnotations() != null && !node.getAnnotations().isEmpty()) { props.put("annotations", String.join(",", node.getAnnotations())); } + // Pre-lowered properties for index-backed case-insensitive search + props.put("label_lower", node.getLabel() != null ? node.getLabel().toLowerCase() : ""); + if (node.getFqn() != null) props.put("fqn_lower", node.getFqn().toLowerCase()); if (node.getProperties() != null) { for (var entry : node.getProperties().entrySet()) { if (entry.getValue() != null) { @@ -249,10 +254,11 @@ public List search(String text) { } public List search(String text, int limit) { + String lowerText = text.toLowerCase(); return queryNodes( - "MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) " - + "OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit", - Map.of("text", text, "limit", limit)); + "MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text " + + "OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit", + Map.of("text", lowerText, "limit", limit)); } public List findNeighbors(String nodeId) { @@ -273,6 +279,31 @@ public List findIncomingNeighbors(String nodeId) { Map.of("nodeId", nodeId)); } + /** + * Batch-find all ENDPOINT/WEBSOCKET_ENDPOINT neighbors for a list of node IDs in one query. + * Returns a map of sourceNodeId -> list of endpoint neighbor nodes. + */ + public Map> findEndpointNeighborsBatch(List nodeIds) { + Map> result = new java.util.LinkedHashMap<>(); + if (nodeIds.isEmpty()) return result; + try (Transaction tx = graphDb.beginTx()) { + var queryResult = tx.execute( + "MATCH (n:CodeNode)-[]-(m:CodeNode) " + + "WHERE n.id IN $nodeIds AND m.kind IN ['ENDPOINT', 'WEBSOCKET_ENDPOINT'] " + + "RETURN n.id AS sourceId, m", + Map.of("nodeIds", nodeIds)); + while (queryResult.hasNext()) { + var row = queryResult.next(); + String sourceId = (String) row.get("sourceId"); + Object val = row.get("m"); + if (val instanceof org.neo4j.graphdb.Node neo4jNode) { + result.computeIfAbsent(sourceId, k -> new ArrayList<>()).add(nodeFromNeo4j(neo4jNode)); + } + } + } + return result; + } + public long count() { try (Transaction tx = graphDb.beginTx()) { var result = tx.execute("MATCH (n:CodeNode) RETURN count(n) AS cnt"); diff --git a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java index 26555192..7800e104 100644 --- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java +++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java @@ -337,19 +337,24 @@ public Map findRelatedEndpoints(String identifier) { Set seenIds = new java.util.LinkedHashSet<>(); List> endpoints = new ArrayList<>(); + // First pass: collect matches that are themselves endpoints for (CodeNode match : matches) { if (match.getKind() == NodeKind.ENDPOINT || match.getKind() == NodeKind.WEBSOCKET_ENDPOINT) { if (seenIds.add(match.getId())) { endpoints.add(nodeToMap(match)); } } - // Check neighbors for connected endpoints - List neighbors = graphStore.findNeighbors(match.getId()); - for (CodeNode neighbor : neighbors) { - if ((neighbor.getKind() == NodeKind.ENDPOINT || neighbor.getKind() == NodeKind.WEBSOCKET_ENDPOINT) - && seenIds.add(neighbor.getId())) { + } + + // Single batched query for all endpoint neighbors (replaces N+1 loop) + List matchIds = matches.stream().map(CodeNode::getId).toList(); + Map> endpointNeighbors = graphStore.findEndpointNeighborsBatch(matchIds); + for (Map.Entry> entry : endpointNeighbors.entrySet()) { + String sourceId = entry.getKey(); + for (CodeNode neighbor : entry.getValue()) { + if (seenIds.add(neighbor.getId())) { Map epMap = nodeToMap(neighbor); - epMap.put("connected_via", match.getId()); + epMap.put("connected_via", sourceId); endpoints.add(epMap); } } diff --git a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java index da4750a6..29740ad1 100644 --- a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java +++ b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java @@ -502,6 +502,59 @@ void findDeadCodeShouldReturnEmptyWhenAllNodesHaveSemanticEdges() { assertTrue(deadCode.isEmpty()); } + // --- findRelatedEndpoints --- + + @Test + void findRelatedEndpointsShouldUsesBatchQueryInsteadOfNPlusOne() { + var classNode = makeNode("cls:UserService", NodeKind.CLASS, "UserService"); + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + when(graphStore.search("UserService", 50)).thenReturn(List.of(classNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("cls:UserService"))) + .thenReturn(Map.of("cls:UserService", List.of(endpointNode))); + + Map result = service.findRelatedEndpoints("UserService"); + + assertEquals("UserService", result.get("identifier")); + assertEquals(1, result.get("count")); + assertEquals(1, result.get("searched_nodes")); + @SuppressWarnings("unchecked") + List> endpoints = (List>) result.get("endpoints"); + assertEquals("ep:getUsers", endpoints.getFirst().get("id")); + assertEquals("cls:UserService", endpoints.getFirst().get("connected_via")); + // Verify no per-node findNeighbors calls were made + verify(graphStore, never()).findNeighbors(anyString()); + } + + @Test + void findRelatedEndpointsShouldIncludeDirectEndpointMatches() { + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + when(graphStore.search("getUsers", 50)).thenReturn(List.of(endpointNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers"))).thenReturn(Map.of()); + + Map result = service.findRelatedEndpoints("getUsers"); + + assertEquals(1, result.get("count")); + @SuppressWarnings("unchecked") + List> endpoints = (List>) result.get("endpoints"); + assertEquals("ep:getUsers", endpoints.getFirst().get("id")); + // Direct endpoint matches have no connected_via + assertNull(endpoints.getFirst().get("connected_via")); + } + + @Test + void findRelatedEndpointsShouldDeduplicateEndpoints() { + var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers"); + // Same endpoint appears as both a direct match and a neighbor + when(graphStore.search("ep", 50)).thenReturn(List.of(endpointNode)); + when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers"))) + .thenReturn(Map.of("ep:getUsers", List.of(endpointNode))); + + Map result = service.findRelatedEndpoints("ep"); + + // Should only appear once + assertEquals(1, result.get("count")); + } + // --- nodeToMap --- @Test