Skip to content

Commit

Permalink
[lookup-by-path] Add removeItem, exclude undefined
Browse files Browse the repository at this point in the history
  • Loading branch information
dmichon-msft committed Dec 18, 2024
1 parent fc04182 commit ae94f6c
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
],
Expand Down
7 changes: 4 additions & 3 deletions common/reviews/api/lookup-by-path.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
```ts

// @beta
export interface IPrefixMatch<TItem> {
export interface IPrefixMatch<TItem extends {} | null> {
index: number;
lastMatch?: IPrefixMatch<TItem>;
value: TItem;
}

// @beta
export interface IReadonlyLookupByPath<TItem> extends Iterable<[string, TItem]> {
export interface IReadonlyLookupByPath<TItem extends {} | null> 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;
Expand All @@ -25,10 +25,11 @@ export interface IReadonlyLookupByPath<TItem> extends Iterable<[string, TItem]>
}

// @beta
export class LookupByPath<TItem> implements IReadonlyLookupByPath<TItem> {
export class LookupByPath<TItem extends {} | null> implements IReadonlyLookupByPath<TItem> {
[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;
Expand Down
27 changes: 23 additions & 4 deletions libraries/lookup-by-path/src/LookupByPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* A node in the path trie used in LookupByPath
*/
interface IPathTrieNode<TItem> {
interface IPathTrieNode<TItem extends {} | null> {
/**
* The value that exactly matches the current relative path
*/
Expand All @@ -31,7 +31,7 @@ interface IPrefixEntry {
*
* @beta
*/
export interface IPrefixMatch<TItem> {
export interface IPrefixMatch<TItem extends {} | null> {
/**
* The item that matched the prefix
*/
Expand All @@ -51,7 +51,7 @@ export interface IPrefixMatch<TItem> {
*
* @beta
*/
export interface IReadonlyLookupByPath<TItem> extends Iterable<[string, TItem]> {
export interface IReadonlyLookupByPath<TItem extends {} | null> extends Iterable<[string, TItem]> {
/**
* Searches for the item associated with `childPath`, or the nearest ancestor of that path that
* has an associated item.
Expand Down Expand Up @@ -178,7 +178,7 @@ export interface IReadonlyLookupByPath<TItem> extends Iterable<[string, TItem]>
* ```
* @beta
*/
export class LookupByPath<TItem> implements IReadonlyLookupByPath<TItem> {
export class LookupByPath<TItem extends {} | null> implements IReadonlyLookupByPath<TItem> {
/**
* The delimiter used to split paths
*/
Expand Down Expand Up @@ -284,6 +284,25 @@ export class LookupByPath<TItem> implements IReadonlyLookupByPath<TItem> {
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<TItem> | 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.
Expand Down
75 changes: 75 additions & 0 deletions libraries/lookup-by-path/src/test/LookupByPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = 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<number> = 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<number> = 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<number> = 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<number> = 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);
Expand Down

0 comments on commit ae94f6c

Please sign in to comment.