From 29b5cbda9633c52b233d6e48dd09d62791ce5470 Mon Sep 17 00:00:00 2001 From: David Michon Date: Tue, 13 May 2025 00:49:33 +0000 Subject: [PATCH] [lookup-by-path] Add deleteSubtree --- ...ookup-delete-subtree_2025-05-13-00-49.json | 10 +++ common/reviews/api/lookup-by-path.api.md | 1 + libraries/lookup-by-path/src/LookupByPath.ts | 32 ++++++++ .../src/test/LookupByPath.test.ts | 75 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 common/changes/@rushstack/lookup-by-path/lookup-delete-subtree_2025-05-13-00-49.json diff --git a/common/changes/@rushstack/lookup-by-path/lookup-delete-subtree_2025-05-13-00-49.json b/common/changes/@rushstack/lookup-by-path/lookup-delete-subtree_2025-05-13-00-49.json new file mode 100644 index 00000000000..29b18cdef19 --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/lookup-delete-subtree_2025-05-13-00-49.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lookup-by-path", + "comment": "Add `deleteSubtree` method.", + "type": "minor" + } + ], + "packageName": "@rushstack/lookup-by-path" +} \ No newline at end of file diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index c6646149dff..6c69844f179 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -50,6 +50,7 @@ export class LookupByPath implements IReadonlyLookupByPath, delimiter?: string); clear(): this; deleteItem(query: string, delimeter?: string): boolean; + deleteSubtree(query: string, delimeter?: string): boolean; readonly delimiter: string; entries(query?: string, delimiter?: string): IterableIterator<[string, TItem]>; findChildPath(childPath: string, delimiter?: string): TItem | undefined; diff --git a/libraries/lookup-by-path/src/LookupByPath.ts b/libraries/lookup-by-path/src/LookupByPath.ts index 957944ed83d..57ef7370e90 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -342,6 +342,38 @@ export class LookupByPath implements IReadonlyLookupByPath | undefined = this._findNodeAtPrefix(query, delimeter); + if (!queryNode) { + return false; + } + + const queue: IPathTrieNode[] = [queryNode]; + let removed: number = 0; + while (queue.length > 0) { + const node: IPathTrieNode = queue.pop()!; + if (node.value !== undefined) { + node.value = undefined; + removed++; + } + if (node.children) { + for (const child of node.children.values()) { + queue.push(child); + } + node.children.clear(); + } + } + + this._size -= removed; + return removed > 0; + } + /** * Associates the value with the specified path. * If a value is already associated, will overwrite. diff --git a/libraries/lookup-by-path/src/test/LookupByPath.test.ts b/libraries/lookup-by-path/src/test/LookupByPath.test.ts index 369de61d4f2..d785252dccb 100644 --- a/libraries/lookup-by-path/src/test/LookupByPath.test.ts +++ b/libraries/lookup-by-path/src/test/LookupByPath.test.ts @@ -359,6 +359,81 @@ describe(LookupByPath.prototype.deleteItem.name, () => { }); }); +describe(LookupByPath.prototype.deleteSubtree.name, () => { + it('returns false for an empty tree', () => { + expect(new LookupByPath().deleteSubtree('foo')).toEqual(false); + }); + + it('deletes the matching node in a trivial tree', () => { + const tree = new LookupByPath([['foo', 1]]); + expect(tree.deleteSubtree('foo')).toEqual(true); + expect(tree.size).toEqual(0); + expect(tree.get('foo')).toEqual(undefined); + }); + + it('returns false for non-matching paths in a single-layer tree', () => { + const tree: LookupByPath = new LookupByPath([ + ['foo', 1], + ['bar', 2], + ['baz', 3] + ]); + + expect(tree.deleteSubtree('buzz')).toEqual(false); + expect(tree.size).toEqual(3); + }); + + it('deletes the matching node in a single-layer tree', () => { + const tree: LookupByPath = new LookupByPath([ + ['foo', 1], + ['bar', 2], + ['baz', 3] + ]); + + expect(tree.deleteSubtree('bar')).toEqual(true); + expect(tree.size).toEqual(2); + expect(tree.get('bar')).toEqual(undefined); + }); + + it('deletes the matching subtree in a multi-layer tree', () => { + const tree: LookupByPath = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['foo/bar/baz', 3] + ]); + + expect(tree.deleteSubtree('foo/bar')).toEqual(true); + expect(tree.size).toEqual(1); + expect(tree.get('foo/bar')).toEqual(undefined); + expect(tree.get('foo/bar/baz')).toEqual(undefined); // child nodes are deleted + }); + + it('returns false for non-matching paths in a multi-layer tree', () => { + const tree: LookupByPath = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['foo/bar/baz', 3] + ]); + + expect(tree.deleteSubtree('foo/baz')).toEqual(false); + expect(tree.size).toEqual(3); + }); + + it('handles custom delimiters', () => { + const tree: LookupByPath = new LookupByPath( + [ + ['foo,bar', 1], + ['foo,bar,baz', 2] + ], + ',' + ); + + expect(tree.deleteSubtree('foo\0bar', '\0')).toEqual(true); + expect(tree.size).toEqual(0); + expect(tree.get('foo\0bar', '\0')).toEqual(undefined); + expect(tree.get('foo\0bar\0baz', '\0')).toEqual(undefined); // child nodes are deleted + }); +}); + describe(LookupByPath.prototype.findChildPath.name, () => { it('returns empty for an empty tree', () => { expect(new LookupByPath().findChildPath('foo')).toEqual(undefined);