Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public interface GraphRepository extends Neo4jRepository<CodeNode, String> {
@Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n")
List<CodeNode> 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<CodeNode> search(String text, int limit);

@Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n")
Expand Down
39 changes: 35 additions & 4 deletions src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
* Creates an index on CodeNode.id for fast MATCH during edge creation.
* Logs progress every 10K items for visibility on large graphs.
*/
public void bulkSave(List<CodeNode> nodes) {

Check warning on line 71 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 84 to 64, Complexity from 19 to 14, Nesting Level from 3 to 2, Number of Variables from 29 to 6.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1J2jEQPVQoes3YmV0Z&open=AZ1J2jEQPVQoes3YmV0Z&pullRequest=13
if (nodes.isEmpty()) return;
long start = System.currentTimeMillis();

Expand All @@ -84,9 +84,11 @@
}
} 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();
}

Expand Down Expand Up @@ -168,7 +170,7 @@
}

/** Convert a CodeNode to a flat property map for Cypher SET. */
private Map<String, Object> nodeToProps(CodeNode node) {

Check failure on line 173 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1J2jEQPVQoes3YmV0Y&open=AZ1J2jEQPVQoes3YmV0Y&pullRequest=13
Map<String, Object> props = new HashMap<>();
props.put("id", node.getId());
props.put("kind", node.getKind().getValue());
Expand All @@ -182,6 +184,9 @@
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) {
Expand Down Expand Up @@ -249,10 +254,11 @@
}

public List<CodeNode> 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));

Check failure on line 261 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "limit" 8 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1J2jEQPVQoes3YmV0X&open=AZ1J2jEQPVQoes3YmV0X&pullRequest=13
}

public List<CodeNode> findNeighbors(String nodeId) {
Expand All @@ -273,6 +279,31 @@
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<String, List<CodeNode>> findEndpointNeighborsBatch(List<String> nodeIds) {
Map<String, List<CodeNode>> 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");
Expand Down
17 changes: 11 additions & 6 deletions src/main/java/io/github/randomcodespace/iq/query/QueryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -337,19 +337,24 @@ public Map<String, Object> findRelatedEndpoints(String identifier) {
Set<String> seenIds = new java.util.LinkedHashSet<>();
List<Map<String, Object>> 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<CodeNode> 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<String> matchIds = matches.stream().map(CodeNode::getId).toList();
Map<String, List<CodeNode>> endpointNeighbors = graphStore.findEndpointNeighborsBatch(matchIds);
for (Map.Entry<String, List<CodeNode>> entry : endpointNeighbors.entrySet()) {
String sourceId = entry.getKey();
for (CodeNode neighbor : entry.getValue()) {
if (seenIds.add(neighbor.getId())) {
Map<String, Object> epMap = nodeToMap(neighbor);
epMap.put("connected_via", match.getId());
epMap.put("connected_via", sourceId);
endpoints.add(epMap);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> result = service.findRelatedEndpoints("UserService");

assertEquals("UserService", result.get("identifier"));
assertEquals(1, result.get("count"));
assertEquals(1, result.get("searched_nodes"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> endpoints = (List<Map<String, Object>>) 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<String, Object> result = service.findRelatedEndpoints("getUsers");

assertEquals(1, result.get("count"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> endpoints = (List<Map<String, Object>>) 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<String, Object> result = service.findRelatedEndpoints("ep");

// Should only appear once
assertEquals(1, result.get("count"));
}

// --- nodeToMap ---

@Test
Expand Down
Loading