Skip to content

Commit a317b42

Browse files
fix: detect cyclic references in TreeData.setParent() (#23401) (#23405)
Previously, TreeData.setParent() only checked for direct self-parenting (item.equals(parent)) but did not detect transitive cycles where a descendant becomes an ancestor. This could corrupt the tree structure, for example when drag-and-drop in TreeGrid moved a parent node under one of its descendants. Added isAncestorOf() helper method that walks up the ancestor chain to detect if the item being moved is an ancestor of the proposed new parent. If so, setParent() now throws IllegalArgumentException with a clear error message. The cycle check has O(h) complexity where h is the tree height, which is acceptable for typical tree operations. Fixes #19337 Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent 7e22328 commit a317b42

File tree

2 files changed

+67
-0
lines changed

2 files changed

+67
-0
lines changed

flow-data/src/main/java/com/vaadin/flow/data/provider/hierarchy/TreeData.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,14 @@ public void setParent(T item, T parent) {
400400
"Item cannot be the parent of itself");
401401
}
402402

403+
// Check for cyclic reference - item cannot become descendant of its
404+
// own descendant
405+
if (parent != null && isAncestorOf(item, parent)) {
406+
throw new IllegalArgumentException(
407+
"Setting '" + parent + "' as parent of '" + item
408+
+ "' would create a cycle in the tree hierarchy");
409+
}
410+
403411
T oldParent = itemToWrapperMap.get(item).getParent();
404412

405413
if (!Objects.equals(oldParent, parent)) {
@@ -488,4 +496,29 @@ private void addItemsRecursively(Collection<T> items,
488496
addItemsRecursively(childItems, childItemProvider);
489497
});
490498
}
499+
500+
/**
501+
* Checks if the potential ancestor is an ancestor of the potential
502+
* descendant by walking up the ancestor chain from the descendant.
503+
*
504+
* @param potentialAncestor
505+
* the item to check as a potential ancestor
506+
* @param potentialDescendant
507+
* the item to check as a potential descendant
508+
* @return true if {@code potentialAncestor} is an ancestor of
509+
* {@code potentialDescendant}
510+
*/
511+
private boolean isAncestorOf(T potentialAncestor, T potentialDescendant) {
512+
if (potentialDescendant == null) {
513+
return false;
514+
}
515+
T current = itemToWrapperMap.get(potentialDescendant).getParent();
516+
while (current != null) {
517+
if (current.equals(potentialAncestor)) {
518+
return true;
519+
}
520+
current = itemToWrapperMap.get(current).getParent();
521+
}
522+
return false;
523+
}
491524
}

flow-data/src/test/java/com/vaadin/flow/data/provider/hierarchy/TreeDataProviderTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.stream.Collectors;
2323
import java.util.stream.Stream;
2424

25+
import org.junit.Assert;
2526
import org.junit.Test;
2627

2728
import com.vaadin.flow.data.provider.DataProviderTestBase;
@@ -167,6 +168,39 @@ public void treeData_move_after_sibling_different_parents() {
167168
data.moveAfterSibling(root0, wrongSibling);
168169
}
169170

171+
@Test
172+
public void treeData_setParent_direct_cycle_throws() {
173+
// Test direct cycle: parent -> child, then try to set child as parent's
174+
// parent
175+
StrBean parent = rootData.get(0);
176+
StrBean child = data.getChildren(parent).get(0);
177+
178+
// This should throw because it would create cycle: child -> parent ->
179+
// child
180+
IllegalArgumentException exception = Assert.assertThrows(
181+
IllegalArgumentException.class,
182+
() -> data.setParent(parent, child));
183+
assertTrue(exception.getMessage().contains("would create a cycle"));
184+
assertTrue(exception.getMessage().contains(parent.toString()));
185+
assertTrue(exception.getMessage().contains(child.toString()));
186+
}
187+
188+
@Test
189+
public void treeData_setParent_multi_level_cycle_throws() {
190+
// Test multi-level cycle: A -> B -> C, then try to set C as A's parent
191+
StrBean itemA = rootData.get(0);
192+
StrBean itemB = data.getChildren(itemA).get(0);
193+
StrBean itemC = data.getChildren(itemB).get(0);
194+
195+
// This should throw because it would create cycle: C -> A -> B -> C
196+
IllegalArgumentException exception = Assert.assertThrows(
197+
IllegalArgumentException.class,
198+
() -> data.setParent(itemA, itemC));
199+
assertTrue(exception.getMessage().contains("would create a cycle"));
200+
assertTrue(exception.getMessage().contains(itemA.toString()));
201+
assertTrue(exception.getMessage().contains(itemC.toString()));
202+
}
203+
170204
@Test
171205
public void treeData_root_items() {
172206
TreeData<String> data = new TreeData<>();

0 commit comments

Comments
 (0)