[DEFECT] Category delete is not recursive — descendants beyond direct children are orphaned
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Problem Statement
Deleting a category that has descendants leaves orphaned rows in the database. Those rows then render as unexpected top-level categories in the UI. The frontend is not at fault — it calls DELETE /api/v1/categories and refetches the tree; there is no local cache. The category cache (catCache.clearCache()) is also cleared globally on any delete, so this is not a cache artifact.
Two backend defects combine to produce the symptom:
-
Non-recursive cascade.
CategoryAPIImpl.deleteCategoryAndChildren(dotCMS/src/main/java/com/dotmarketing/portlets/categories/business/CategoryAPIImpl.java, ~line 711) callsgetChildren(parent, user, false)which returns direct children only. The innerdelete(category, user, …)at line 71 is also non-recursive — it only callscategoryFactory.delete(category). For a tree of depth N, exactly the top 2 levels are removed; levels 3..N survive and lose their tree links via FK cascade in thetreetable, so they render as new top-level categories with their internal subtrees intact. -
Silent error handling in the REST endpoint.
CategoriesResource.delete(dotCMS/src/main/java/com/dotcms/rest/api/v1/categories/CategoriesResource.java, ~line 695) wraps the API call intry { … } catch (Exception e) { Logger.debug(this, e.getMessage(), e); }and always returns HTTP 200. Any exception (security, DB, cascade failure) is swallowed atDEBUGlevel and never reaches the client, so the UI shows a success toast even when the operation partially or fully failed. The endpoint is also missing@WrapInTransaction— compare with the sibling/_importendpoint at line 955 which has it.
Impact: database grows with orphaned categories on every multi-level delete. The user sees unexpected root categories after refreshing, with no error indication. The deeper the tree, the more data is left behind — the problem scales with depth. This is a sub-issue of the Modernization feedback bucket #36691. Symptom originally captured in #36897.
Steps to Reproduce
Empirical reproduction confirmed against a local dotCMS instance.
- Seed a 3-level tree of 210 categories (10 parents × 5 children × 3 grandchildren). Categories can be created via
POST /api/v1/categorieswith body{ "categoryName": "...", "key": "...", "categoryVelocityVarName": "...", "parent": "<parent-inode-or-omitted-for-root>" }. - Open the Categories portlet, filter by the batch tag.
- Select all 10 top-level parents and delete them at once.
- Query the database:
```sql
SELECT COUNT(*) FROM category WHERE category_name LIKE '%';
```
Observed: 150 rows remain (150 grandchildren). Expected: 0. - Reload the Categories portlet. Observed: the 150 grandchildren now appear as root categories. Expected: the tree is empty for that batch.
Great-grandchild scenario (depth = 4). In a tree Parent → Child → Grandchild → GreatGrandchild, deleting Parent removes only Parent and Child. Grandchild becomes an orphaned root; GreatGrandchild stays nested under Grandchild. Same mechanism, more surviving depth.
Server logs during the reproduction showed ActivityLogger — Delete Category Action … User dotcms.org.1 deleted category list: [...] with no error — the silent-catch masked the incomplete operation.
https://github.com/user-attachments/assets/6cf002ad-d76b-4050-80be-5d994d71b260
Acceptance Criteria
-
CategoryAPIImpl.deleteCategoryAndChildrenremoves the target category and every descendant at every depth. Candidate implementation: consumegetAllChildren(category)(already exists at line 702) and delete leaf-first, or recurse depth-first. -
CategoriesResource.deleteis annotated with@WrapInTransaction. -
CategoriesResource.deleteno longer swallows exceptions atDEBUG. Failures are logged atERRORand surfaced to the client throughBulkResultView.failedResults(or an appropriate non-2xx status for hard failures like security / bad request). - Integration tests in
dotcms-integrationcover:- Delete of a 3-level tree by root →
categoryandtreetables are clean for that subtree (0 rows). - Delete of a 4-level tree by root → same expectation.
- Delete of a category whose descendant is referenced by a
Contentlet→ outcome matches the failure-policy decision below (atomic rollback OR best-effort withfailedResultspopulated). Asserts the client can distinguish success from partial/total failure.
- Delete of a 3-level tree by root →
- Manual QA: reproduce the steps above on a build with the fix and confirm
SELECT COUNT(*)returns 0 after the delete, and the Categories portlet shows the expected empty state.
Design decisions required from the backend owner
Two policy choices need to be made before implementation. Please decide and note them on this issue:
- Failure policy when a descendant has real dependencies (e.g. a
Contentletreferences it):- Atomic — rollback the entire delete if any descendant cannot be removed.
- Best-effort — delete what is possible and report the rest via
BulkResultView.failedResults.
- Permission scope on cascade:
- Root-only — check
PERMISSION_EDITon the selected category only (current behavior). - Every descendant — check
PERMISSION_EDITon every category in the subtree (stricter; may be a breaking change for users who can delete trees they don't fully own).
- Root-only — check
dotCMS Version
Latest from main branch.
Severity
Medium - Some functionality impacted
Links
NA
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.