Skip to content

Commit 9931191

Browse files
[lookup-by-path] Add toJson()/fromJson() methods (#5754)
* feat: add toJson/fromJson methods to LookupByPath for serialization/deserialization Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/3c398d38-9717-42ba-a657-ca2385a2a8f3 Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * fix: use Object.create(null) for dictionary object in toJson serialization Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/bf4dc31b-0da1-4979-b465-0bdff2d00028 Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * chore: add change file for @rushstack/lookup-by-path minor bump Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/a422a6e3-515f-4967-9ae3-eec7adf9edef Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * chore: update change file comment for toJson/fromJson Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/5d9f37a7-dbe9-4977-b391-4a4d359e0856 Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Address PR feedback: backtick formatting in changelog, dynamic describe name, add snapshot tests Agent-Logs-Url: https://github.com/microsoft/rushstack/sessions/f33fe415-7fc0-4905-8330-774013ebbe50 Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
1 parent 48853d6 commit 9931191

6 files changed

Lines changed: 484 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Add `toJson`/`fromJson` methods to `LookupByPath` for serialization/deserialization",
5+
"type": "minor",
6+
"packageName": "@rushstack/lookup-by-path"
7+
}
8+
],
9+
"packageName": "@rushstack/lookup-by-path",
10+
"email": "198982749+Copilot@users.noreply.github.com"
11+
}

common/reviews/api/lookup-by-path.api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export interface IGetFirstDifferenceInCommonNodesOptions<TItem extends {}> {
1616
second: IReadonlyPathTrieNode<TItem>;
1717
}
1818

19+
// @beta
20+
export interface ILookupByPathJson<TSerialized> {
21+
delimiter: string;
22+
tree: ISerializedPathTrieNode;
23+
values: TSerialized[];
24+
}
25+
1926
// @beta
2027
export interface IPrefixMatch<TItem extends {}> {
2128
index: number;
@@ -35,6 +42,7 @@ export interface IReadonlyLookupByPath<TItem extends {}> extends Iterable<[strin
3542
groupByChild<TInfo>(infoByPath: Map<string, TInfo>, delimiter?: string): Map<TItem, Map<string, TInfo>>;
3643
has(query: string, delimiter?: string): boolean;
3744
get size(): number;
45+
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
3846
// (undocumented)
3947
get tree(): IReadonlyPathTrieNode<TItem>;
4048
}
@@ -45,6 +53,12 @@ export interface IReadonlyPathTrieNode<TItem extends {}> {
4553
readonly value: TItem | undefined;
4654
}
4755

56+
// @beta
57+
export interface ISerializedPathTrieNode {
58+
children?: Record<string, ISerializedPathTrieNode>;
59+
valueIndex?: number;
60+
}
61+
4862
// @beta
4963
export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TItem> {
5064
[Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>;
@@ -57,6 +71,7 @@ export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TIt
5771
findChildPath(childPath: string, delimiter?: string): TItem | undefined;
5872
findChildPathFromSegments(childPathSegments: Iterable<string>): TItem | undefined;
5973
findLongestPrefixMatch(query: string, delimiter?: string): IPrefixMatch<TItem> | undefined;
74+
static fromJson<TItem extends {}, TSerialized>(json: ILookupByPathJson<TSerialized>, deserializeValue: (serialized: TSerialized) => TItem): LookupByPath<TItem>;
6075
get(key: string, delimiter?: string): TItem | undefined;
6176
getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode<TItem> | undefined;
6277
groupByChild<TInfo>(infoByPath: Map<string, TInfo>, delimiter?: string): Map<TItem, Map<string, TInfo>>;
@@ -65,6 +80,7 @@ export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TIt
6580
setItem(serializedPath: string, value: TItem, delimiter?: string): this;
6681
setItemFromSegments(pathSegments: Iterable<string>, value: TItem): this;
6782
get size(): number;
83+
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
6884
// (undocumented)
6985
get tree(): IReadonlyPathTrieNode<TItem>;
7086
}

libraries/lookup-by-path/src/LookupByPath.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,53 @@ export interface IReadonlyPathTrieNode<TItem extends {}> {
3636
readonly children: ReadonlyMap<string, IReadonlyPathTrieNode<TItem>> | undefined;
3737
}
3838

39+
/**
40+
* JSON-serializable representation of a node in a {@link LookupByPath} trie.
41+
*
42+
* @beta
43+
*/
44+
export interface ISerializedPathTrieNode {
45+
/**
46+
* Index into the `values` array of the containing {@link ILookupByPathJson}.
47+
* If `undefined`, this node has no associated value.
48+
*/
49+
valueIndex?: number;
50+
51+
/**
52+
* Child nodes keyed by path segment.
53+
*/
54+
children?: Record<string, ISerializedPathTrieNode>;
55+
}
56+
57+
/**
58+
* JSON-serializable representation of a {@link LookupByPath} instance.
59+
*
60+
* @remarks
61+
* The `values` array stores each unique value exactly once (by reference identity).
62+
* Nodes in the tree reference values by their index in this array, which ensures that
63+
* reference equality is preserved across serialization and deserialization.
64+
*
65+
* @beta
66+
*/
67+
export interface ILookupByPathJson<TSerialized> {
68+
/**
69+
* The path delimiter used by the serialized trie.
70+
*/
71+
delimiter: string;
72+
73+
/**
74+
* Array of serialized values. Nodes in the tree reference values by their index in this array.
75+
* Using an array with index-based references preserves reference equality: if multiple nodes
76+
* share the same value (by reference), they will reference the same index.
77+
*/
78+
values: TSerialized[];
79+
80+
/**
81+
* The serialized tree structure.
82+
*/
83+
tree: ISerializedPathTrieNode;
84+
}
85+
3986
interface IPrefixEntry {
4087
/**
4188
* The prefix that was matched
@@ -195,6 +242,18 @@ export interface IReadonlyLookupByPath<TItem extends {}> extends Iterable<[strin
195242
* @returns The trie node at the specified prefix, or `undefined` if no node was found
196243
*/
197244
getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode<TItem> | undefined;
245+
246+
/**
247+
* Serializes this `LookupByPath` instance to a JSON-compatible representation.
248+
*
249+
* @param serializeValue - A function that converts a value of type `TItem` to a JSON-serializable form.
250+
* @returns A JSON-serializable representation of this trie.
251+
*
252+
* @remarks
253+
* Values that are reference-equal will be serialized once and referenced by index, ensuring
254+
* that reference equality is preserved when deserialized via {@link LookupByPath.fromJson}.
255+
*/
256+
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
198257
}
199258

200259
/**
@@ -545,6 +604,100 @@ export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TIt
545604
return this._findNodeAtPrefix(query, delimiter);
546605
}
547606

607+
/**
608+
* {@inheritdoc IReadonlyLookupByPath.toJson}
609+
*/
610+
public toJson<TSerialized>(
611+
serializeValue: (value: TItem) => TSerialized
612+
): ILookupByPathJson<TSerialized> {
613+
const valueToIndex: Map<TItem, number> = new Map();
614+
const values: TSerialized[] = [];
615+
616+
const getOrAddValueIndex: (value: TItem) => number = (value: TItem) => {
617+
let index: number | undefined = valueToIndex.get(value);
618+
if (index === undefined) {
619+
index = values.length;
620+
valueToIndex.set(value, index);
621+
values.push(serializeValue(value));
622+
}
623+
return index;
624+
};
625+
626+
const serializeNode: (node: IPathTrieNode<TItem>) => ISerializedPathTrieNode = (
627+
node: IPathTrieNode<TItem>
628+
) => {
629+
const result: ISerializedPathTrieNode = {};
630+
631+
if (node.value !== undefined) {
632+
result.valueIndex = getOrAddValueIndex(node.value);
633+
}
634+
635+
if (node.children && node.children.size > 0) {
636+
const children: Record<string, ISerializedPathTrieNode> = Object.create(null);
637+
for (const [segment, child] of node.children) {
638+
children[segment] = serializeNode(child);
639+
}
640+
result.children = children;
641+
}
642+
643+
return result;
644+
};
645+
646+
return {
647+
delimiter: this.delimiter,
648+
values,
649+
tree: serializeNode(this._root)
650+
};
651+
}
652+
653+
/**
654+
* Deserializes a `LookupByPath` instance from a JSON representation previously
655+
* created by {@link LookupByPath.toJson}.
656+
*
657+
* @param json - The JSON representation to deserialize.
658+
* @param deserializeValue - A function that converts a serialized value back to its original type.
659+
* @returns A new `LookupByPath` instance.
660+
*
661+
* @remarks
662+
* Reference equality is preserved: if multiple nodes in the serialized trie pointed at the same
663+
* value (i.e., the same index in the `values` array), the deserialized nodes will share the same
664+
* object reference.
665+
*/
666+
public static fromJson<TItem extends {}, TSerialized>(
667+
json: ILookupByPathJson<TSerialized>,
668+
deserializeValue: (serialized: TSerialized) => TItem
669+
): LookupByPath<TItem> {
670+
const deserializedValues: TItem[] = json.values.map(deserializeValue);
671+
672+
const result: LookupByPath<TItem> = new LookupByPath<TItem>(undefined, json.delimiter);
673+
674+
const deserializeNode: (
675+
jsonNode: ISerializedPathTrieNode,
676+
targetNode: IPathTrieNode<TItem>
677+
) => void = (jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode<TItem>) => {
678+
if (jsonNode.valueIndex !== undefined) {
679+
targetNode.value = deserializedValues[jsonNode.valueIndex];
680+
result._size++;
681+
}
682+
683+
if (jsonNode.children) {
684+
targetNode.children = new Map();
685+
for (const [segment, childJson] of Object.entries(jsonNode.children)) {
686+
const childNode: IPathTrieNode<TItem> = {
687+
value: undefined,
688+
children: undefined
689+
};
690+
targetNode.children.set(segment, childNode);
691+
deserializeNode(childJson, childNode);
692+
}
693+
}
694+
};
695+
696+
deserializeNode(json.tree, result._root);
697+
698+
return result;
699+
}
700+
548701
/**
549702
* Iterates through progressively longer prefixes of a given string and returns as soon
550703
* as the number of candidate items that match the prefix are 1 or 0.

libraries/lookup-by-path/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
* @packageDocumentation
88
*/
99

10-
export type { IPrefixMatch, IReadonlyLookupByPath, IReadonlyPathTrieNode } from './LookupByPath';
10+
export type {
11+
ILookupByPathJson,
12+
IPrefixMatch,
13+
IReadonlyLookupByPath,
14+
IReadonlyPathTrieNode,
15+
ISerializedPathTrieNode
16+
} from './LookupByPath';
1117
export { LookupByPath } from './LookupByPath';
1218
export type { IGetFirstDifferenceInCommonNodesOptions } from './getFirstDifferenceInCommonNodes';
1319
export { getFirstDifferenceInCommonNodes } from './getFirstDifferenceInCommonNodes';

0 commit comments

Comments
 (0)