Skip to content

Commit 1e6bbd7

Browse files
feat: detect items with circular parent/child relationship as a special case of orphans, see #37
1 parent 1ab9bc4 commit 1e6bbd7

File tree

3 files changed

+116
-9
lines changed

3 files changed

+116
-9
lines changed

README.md

+7-7
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,13 @@ Which results in the following array:
6464

6565
You can provide a second argument to arrayToTree with configuration options. Right now, you can set the following:
6666

67-
- `id`: key of the id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"id"`.
68-
- `parentId`: key of the parent's id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"parentId"`.
69-
- `nestedIds`: option to enable/disable nested ids. Default: `true`.
70-
- `childrenField`: key which will contain all child nodes of the parent node. Default: `"children"`
71-
- `dataField`: key which will contain all properties/data of the original items. Set to null if you don't want a container. Default: `"data"`
72-
- `throwIfOrphans`: option to throw an error if the array of items contains one or more items that have no parents in the array. This option has a small runtime penalty, so it's disabled by default. When enabled, the function will throw an error containing the parentIds that were not found in the items array. When disabled, the function will just ignore orphans and not add them to the tree. Default: `false`
73-
- `rootParentIds`: object with parent ids as keys and `true` as values that should be considered the top or root elements of the tree. This is useful when your tree is a subset of full tree, which means there is no item whose parent id is one of `undefined`, `null` or `''`. The array you pass in will be replace the default value. `undefined` and `null` are always considered to be rootParentIds. For more details, see [#23](https://github.com/philipstanislaus/performant-array-to-tree/issues/23). Default: `{'': true}`
67+
- `id`: Key of the id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"id"`.
68+
- `parentId`: Key of the parent's id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"parentId"`.
69+
- `nestedIds`: Option to enable/disable nested ids. Default: `true`.
70+
- `childrenField`: Key which will contain all child nodes of the parent node. Default: `"children"`
71+
- `dataField`: Key which will contain all properties/data of the original items. Set to null if you don't want a container. Default: `"data"`
72+
- `throwIfOrphans`: Option to throw an error if the array of items contains one or more items that have no parents in the array or if the array of items contains items with a circular parent/child relationship. This option has a small runtime penalty, so it's disabled by default. When enabled, the function will throw an error containing the parentIds that were not found in the items array, or in the case of only circular item relationships a generic error. The function will throw an error if the number of nodes in the tree is smaller than the number of nodes in the original array. When disabled, the function will just ignore orphans and circular relationships and not add them to the tree. Default: `false`
73+
- `rootParentIds`: Object with parent ids as keys and `true` as values that should be considered the top or root elements of the tree. This is useful when your tree is a subset of full tree, which means there is no item whose parent id is one of `undefined`, `null` or `''`. The array you pass in will be replace the default value. `undefined` and `null` are always considered to be rootParentIds. For more details, see [#23](https://github.com/philipstanislaus/performant-array-to-tree/issues/23). Default: `{'': true}`
7474

7575
Example:
7676

src/arrayToTree.spec.ts

+92-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from "chai";
2-
import { arrayToTree } from "./arrayToTree";
2+
import { arrayToTree, countNodes } from "./arrayToTree";
33

44
describe("arrayToTree", () => {
55
it("should work with nested objects", () => {
@@ -28,6 +28,68 @@ describe("arrayToTree", () => {
2828
]);
2929
});
3030

31+
it("should work with nested objects if throwIfOrphans is set to true", () => {
32+
expect(
33+
arrayToTree([
34+
{ id: "4", parentId: null, custom: "abc" },
35+
{ id: "31", parentId: "4", custom: "12" },
36+
{ id: "1941", parentId: "418", custom: "de" },
37+
{ id: "1", parentId: "418", custom: "ZZZz" },
38+
{ id: "418", parentId: null, custom: "ü" },
39+
], { throwIfOrphans: true })
40+
).to.deep.equal([
41+
{
42+
data: { id: "4", parentId: null, custom: "abc" },
43+
children: [
44+
{ data: { id: "31", parentId: "4", custom: "12" }, children: [] },
45+
],
46+
},
47+
{
48+
data: { id: "418", parentId: null, custom: "ü" },
49+
children: [
50+
{ data: { id: "1941", parentId: "418", custom: "de" }, children: [] },
51+
{ data: { id: "1", parentId: "418", custom: "ZZZz" }, children: [] },
52+
],
53+
},
54+
]);
55+
});
56+
57+
it("should ignore circular parent child relations", () => {
58+
expect(
59+
arrayToTree([
60+
{ id: "4", parentId: "31", custom: "abc" },
61+
{ id: "31", parentId: "4", custom: "12" },
62+
])
63+
).to.deep.equal([
64+
]);
65+
66+
expect(
67+
arrayToTree([
68+
{ id: "4", parentId: "31", custom: "abc" },
69+
{ id: "31", parentId: "5", custom: "12" },
70+
{ id: "5", parentId: "4", custom: "12" },
71+
])
72+
).to.deep.equal([
73+
]);
74+
});
75+
76+
it("should throw if throwIfOrphans is enabled and circular parent child relations are encountered, see #37", () => {
77+
expect(
78+
() => arrayToTree([
79+
{ id: "4", parentId: "31", custom: "abc" },
80+
{ id: "31", parentId: "4", custom: "12" },
81+
], { throwIfOrphans: true })
82+
).to.throw('The items array contains nodes with a circular parent/child relationship.');
83+
84+
expect(
85+
() => arrayToTree([
86+
{ id: "4", parentId: "31", custom: "abc" },
87+
{ id: "31", parentId: "5", custom: "12" },
88+
{ id: "5", parentId: "4", custom: "12" },
89+
], { throwIfOrphans: true })
90+
).to.throw('The items array contains nodes with a circular parent/child relationship.');
91+
});
92+
3193
it("should work with integer keys", () => {
3294
expect(
3395
arrayToTree([
@@ -622,3 +684,32 @@ describe("arrayToTree", () => {
622684
]);
623685
});
624686
});
687+
688+
describe("countNodes", () => {
689+
it("should work with nested objects", () => {
690+
expect(
691+
countNodes(arrayToTree([
692+
{ id: "4", parentId: null, custom: "abc" },
693+
{ id: "31", parentId: "4", custom: "12" },
694+
{ id: "1941", parentId: "418", custom: "de" },
695+
{ id: "1", parentId: "418", custom: "ZZZz" },
696+
{ id: "418", parentId: null, custom: "ü" },
697+
]), 'children')
698+
).to.equal(5);
699+
});
700+
701+
it("should work for 1 node", () => {
702+
expect(
703+
countNodes(arrayToTree([
704+
{ id: "4", parentId: null, custom: "abc" },
705+
]), 'children')
706+
).to.equal(1);
707+
});
708+
709+
it("should work for 0 nodes", () => {
710+
expect(
711+
countNodes(arrayToTree([
712+
]), 'children')
713+
).to.equal(0);
714+
});
715+
});

src/arrayToTree.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function arrayToTree(
4343

4444
// stores all item ids that have not been added to the resulting unflattened tree yet
4545
// this is an opt-in property, since it has a slight runtime overhead
46-
const orphanIds: null | Set<string | number> = config.throwIfOrphans
46+
const orphanIds: null | Set<string | number> = conf.throwIfOrphans
4747
? new Set()
4848
: null;
4949

@@ -129,9 +129,25 @@ export function arrayToTree(
129129
);
130130
}
131131

132+
if (conf.throwIfOrphans && countNodes(rootItems, conf.childrenField) < Object.keys(lookup).length) {
133+
throw new Error(
134+
`The items array contains nodes with a circular parent/child relationship.`
135+
);
136+
}
137+
132138
return rootItems;
133139
}
134140

141+
/**
142+
* Returns the number of nodes in a tree in a recursive way
143+
* @param tree An array of nodes (tree items), each having a field `childrenField` that contains an array of nodes
144+
* @param childrenField Name of the property that contains the array of child nodes
145+
* @returns Number of nodes in the tree
146+
*/
147+
export function countNodes(tree: TreeItem[], childrenField: string): number {
148+
return tree.reduce((sum, n) => sum + 1 + (n[childrenField] && countNodes(n[childrenField], childrenField)), 0);
149+
}
150+
135151
/**
136152
* Returns the value of a nested property inside an item
137153
* Example: user can access 'id', or 'parentId' inside item = { nestedObject: { id: 'myId', parentId: 'myParentId' } }

0 commit comments

Comments
 (0)