From ae94f6cf271a13c43722e8657508dc6f466a595b Mon Sep 17 00:00:00 2001 From: David Michon Date: Wed, 18 Dec 2024 00:04:11 +0000 Subject: [PATCH] [lookup-by-path] Add removeItem, exclude undefined --- .../lookup-enhancements_2024-12-17-22-37.json | 2 +- common/reviews/api/lookup-by-path.api.md | 7 +- libraries/lookup-by-path/src/LookupByPath.ts | 27 ++++++- .../src/test/LookupByPath.test.ts | 75 +++++++++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/common/changes/@rushstack/lookup-by-path/lookup-enhancements_2024-12-17-22-37.json b/common/changes/@rushstack/lookup-by-path/lookup-enhancements_2024-12-17-22-37.json index 13934dfab3..10c991a701 100644 --- a/common/changes/@rushstack/lookup-by-path/lookup-enhancements_2024-12-17-22-37.json +++ b/common/changes/@rushstack/lookup-by-path/lookup-enhancements_2024-12-17-22-37.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/lookup-by-path", - "comment": "Update all methods to accept optional override delimiters. Add `size`, `entries(), `get()`, and `has()`. Make class iterable.", + "comment": "Update all methods to accept optional override delimiters. Add `size`, `entries(), `get()`, `has()`, `removeItem()`. Make class iterable.\nExplicitly exclude `undefined` from the allowed types for the type parameter `TItem`.", "type": "minor" } ], diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index b2cabba8e8..8289f3ffd1 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -5,14 +5,14 @@ ```ts // @beta -export interface IPrefixMatch { +export interface IPrefixMatch { index: number; lastMatch?: IPrefixMatch; value: TItem; } // @beta -export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> { +export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> { [Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>; entries(query?: string, delimiter?: string): IterableIterator<[string, TItem]>; findChildPath(childPath: string, delimiter?: string): TItem | undefined; @@ -25,10 +25,11 @@ export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> } // @beta -export class LookupByPath implements IReadonlyLookupByPath { +export class LookupByPath implements IReadonlyLookupByPath { [Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>; constructor(entries?: Iterable<[string, TItem]>, delimiter?: string); clear(): this; + deleteItem(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 9909c2a9b5..7cf2e6b27d 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -4,7 +4,7 @@ /** * A node in the path trie used in LookupByPath */ -interface IPathTrieNode { +interface IPathTrieNode { /** * The value that exactly matches the current relative path */ @@ -31,7 +31,7 @@ interface IPrefixEntry { * * @beta */ -export interface IPrefixMatch { +export interface IPrefixMatch { /** * The item that matched the prefix */ @@ -51,7 +51,7 @@ export interface IPrefixMatch { * * @beta */ -export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> { +export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> { /** * Searches for the item associated with `childPath`, or the nearest ancestor of that path that * has an associated item. @@ -178,7 +178,7 @@ export interface IReadonlyLookupByPath extends Iterable<[string, TItem]> * ``` * @beta */ -export class LookupByPath implements IReadonlyLookupByPath { +export class LookupByPath implements IReadonlyLookupByPath { /** * The delimiter used to split paths */ @@ -284,6 +284,25 @@ export class LookupByPath implements IReadonlyLookupByPath { return this.setItemFromSegments(LookupByPath.iteratePathSegments(serializedPath, delimiter), value); } + /** + * Deletes an item if it exists. + * @param query - The path to the item to delete + * @param delimeter - Optional override delimeter for parsing the query + * @returns `true` if the item was found and deleted, `false` otherwise + * @remarks + * If the node has children with values, they will be retained. + */ + public deleteItem(query: string, delimeter: string = this.delimiter): boolean { + const node: IPathTrieNode | undefined = this._findNodeAtPrefix(query, delimeter); + if (node?.value !== undefined) { + node.value = undefined; + this._size--; + return true; + } + + return false; + } + /** * 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 4d2fcb3ec7..369de61d4f 100644 --- a/libraries/lookup-by-path/src/test/LookupByPath.test.ts +++ b/libraries/lookup-by-path/src/test/LookupByPath.test.ts @@ -284,6 +284,81 @@ describe(LookupByPath.prototype.entries.name, () => { }); }); +describe(LookupByPath.prototype.deleteItem.name, () => { + it('returns false for an empty tree', () => { + expect(new LookupByPath().deleteItem('foo')).toEqual(false); + }); + + it('deletes the matching node in a trivial tree', () => { + const tree = new LookupByPath([['foo', 1]]); + expect(tree.deleteItem('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.deleteItem('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.deleteItem('bar')).toEqual(true); + expect(tree.size).toEqual(2); + expect(tree.get('bar')).toEqual(undefined); + }); + + it('deletes the matching node in a multi-layer tree', () => { + const tree: LookupByPath = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['foo/bar/baz', 3] + ]); + + expect(tree.deleteItem('foo/bar')).toEqual(true); + expect(tree.size).toEqual(2); + expect(tree.get('foo/bar')).toEqual(undefined); + expect(tree.get('foo/bar/baz')).toEqual(3); // child nodes are retained + }); + + 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.deleteItem('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.deleteItem('foo\0bar', '\0')).toEqual(true); + expect(tree.size).toEqual(1); + expect(tree.get('foo\0bar', '\0')).toEqual(undefined); + expect(tree.get('foo\0bar\0baz', '\0')).toEqual(2); // child nodes are retained + }); +}); + describe(LookupByPath.prototype.findChildPath.name, () => { it('returns empty for an empty tree', () => { expect(new LookupByPath().findChildPath('foo')).toEqual(undefined);