Skip to content

Commit

Permalink
feat: detect items with circular parent/child relationship as a speci…
Browse files Browse the repository at this point in the history
…al case of orphans, see #37
  • Loading branch information
philipstanislaus committed Feb 17, 2022
1 parent 1ab9bc4 commit 1e6bbd7
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 9 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ Which results in the following array:

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

- `id`: key of the id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"id"`.
- `parentId`: key of the parent's id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"parentId"`.
- `nestedIds`: option to enable/disable nested ids. Default: `true`.
- `childrenField`: key which will contain all child nodes of the parent node. Default: `"children"`
- `dataField`: key which will contain all properties/data of the original items. Set to null if you don't want a container. Default: `"data"`
- `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`
- `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}`
- `id`: Key of the id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"id"`.
- `parentId`: Key of the parent's id field of the item. Also works with nested properties (e. g. `"nested.parentId"`). Default: `"parentId"`.
- `nestedIds`: Option to enable/disable nested ids. Default: `true`.
- `childrenField`: Key which will contain all child nodes of the parent node. Default: `"children"`
- `dataField`: Key which will contain all properties/data of the original items. Set to null if you don't want a container. Default: `"data"`
- `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`
- `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}`

Example:

Expand Down
93 changes: 92 additions & 1 deletion src/arrayToTree.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from "chai";
import { arrayToTree } from "./arrayToTree";
import { arrayToTree, countNodes } from "./arrayToTree";

describe("arrayToTree", () => {
it("should work with nested objects", () => {
Expand Down Expand Up @@ -28,6 +28,68 @@ describe("arrayToTree", () => {
]);
});

it("should work with nested objects if throwIfOrphans is set to true", () => {
expect(
arrayToTree([
{ id: "4", parentId: null, custom: "abc" },
{ id: "31", parentId: "4", custom: "12" },
{ id: "1941", parentId: "418", custom: "de" },
{ id: "1", parentId: "418", custom: "ZZZz" },
{ id: "418", parentId: null, custom: "ü" },
], { throwIfOrphans: true })
).to.deep.equal([
{
data: { id: "4", parentId: null, custom: "abc" },
children: [
{ data: { id: "31", parentId: "4", custom: "12" }, children: [] },
],
},
{
data: { id: "418", parentId: null, custom: "ü" },
children: [
{ data: { id: "1941", parentId: "418", custom: "de" }, children: [] },
{ data: { id: "1", parentId: "418", custom: "ZZZz" }, children: [] },
],
},
]);
});

it("should ignore circular parent child relations", () => {
expect(
arrayToTree([
{ id: "4", parentId: "31", custom: "abc" },
{ id: "31", parentId: "4", custom: "12" },
])
).to.deep.equal([
]);

expect(
arrayToTree([
{ id: "4", parentId: "31", custom: "abc" },
{ id: "31", parentId: "5", custom: "12" },
{ id: "5", parentId: "4", custom: "12" },
])
).to.deep.equal([
]);
});

it("should throw if throwIfOrphans is enabled and circular parent child relations are encountered, see #37", () => {
expect(
() => arrayToTree([
{ id: "4", parentId: "31", custom: "abc" },
{ id: "31", parentId: "4", custom: "12" },
], { throwIfOrphans: true })
).to.throw('The items array contains nodes with a circular parent/child relationship.');

expect(
() => arrayToTree([
{ id: "4", parentId: "31", custom: "abc" },
{ id: "31", parentId: "5", custom: "12" },
{ id: "5", parentId: "4", custom: "12" },
], { throwIfOrphans: true })
).to.throw('The items array contains nodes with a circular parent/child relationship.');
});

it("should work with integer keys", () => {
expect(
arrayToTree([
Expand Down Expand Up @@ -622,3 +684,32 @@ describe("arrayToTree", () => {
]);
});
});

describe("countNodes", () => {
it("should work with nested objects", () => {
expect(
countNodes(arrayToTree([
{ id: "4", parentId: null, custom: "abc" },
{ id: "31", parentId: "4", custom: "12" },
{ id: "1941", parentId: "418", custom: "de" },
{ id: "1", parentId: "418", custom: "ZZZz" },
{ id: "418", parentId: null, custom: "ü" },
]), 'children')
).to.equal(5);
});

it("should work for 1 node", () => {
expect(
countNodes(arrayToTree([
{ id: "4", parentId: null, custom: "abc" },
]), 'children')
).to.equal(1);
});

it("should work for 0 nodes", () => {
expect(
countNodes(arrayToTree([
]), 'children')
).to.equal(0);
});
});
18 changes: 17 additions & 1 deletion src/arrayToTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function arrayToTree(

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

Expand Down Expand Up @@ -129,9 +129,25 @@ export function arrayToTree(
);
}

if (conf.throwIfOrphans && countNodes(rootItems, conf.childrenField) < Object.keys(lookup).length) {
throw new Error(
`The items array contains nodes with a circular parent/child relationship.`
);
}

return rootItems;
}

/**
* Returns the number of nodes in a tree in a recursive way
* @param tree An array of nodes (tree items), each having a field `childrenField` that contains an array of nodes
* @param childrenField Name of the property that contains the array of child nodes
* @returns Number of nodes in the tree
*/
export function countNodes(tree: TreeItem[], childrenField: string): number {
return tree.reduce((sum, n) => sum + 1 + (n[childrenField] && countNodes(n[childrenField], childrenField)), 0);
}

/**
* Returns the value of a nested property inside an item
* Example: user can access 'id', or 'parentId' inside item = { nestedObject: { id: 'myId', parentId: 'myParentId' } }
Expand Down

0 comments on commit 1e6bbd7

Please sign in to comment.