Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Add toJson/fromJson methods to LookupByPath for serialization/deserialization",
Comment thread
iclanton marked this conversation as resolved.
Outdated
"type": "minor",
"packageName": "@rushstack/lookup-by-path"
}
],
"packageName": "@rushstack/lookup-by-path",
"email": "198982749+Copilot@users.noreply.github.com"
}
16 changes: 16 additions & 0 deletions common/reviews/api/lookup-by-path.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface IGetFirstDifferenceInCommonNodesOptions<TItem extends {}> {
second: IReadonlyPathTrieNode<TItem>;
}

// @beta
export interface ILookupByPathJson<TSerialized> {
delimiter: string;
tree: ISerializedPathTrieNode;
values: TSerialized[];
}

// @beta
export interface IPrefixMatch<TItem extends {}> {
index: number;
Expand All @@ -35,6 +42,7 @@ export interface IReadonlyLookupByPath<TItem extends {}> extends Iterable<[strin
groupByChild<TInfo>(infoByPath: Map<string, TInfo>, delimiter?: string): Map<TItem, Map<string, TInfo>>;
has(query: string, delimiter?: string): boolean;
get size(): number;
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
// (undocumented)
get tree(): IReadonlyPathTrieNode<TItem>;
}
Expand All @@ -45,6 +53,12 @@ export interface IReadonlyPathTrieNode<TItem extends {}> {
readonly value: TItem | undefined;
}

// @beta
export interface ISerializedPathTrieNode {
children?: Record<string, ISerializedPathTrieNode>;
valueIndex?: number;
}

// @beta
export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TItem> {
[Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>;
Expand All @@ -57,6 +71,7 @@ export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TIt
findChildPath(childPath: string, delimiter?: string): TItem | undefined;
findChildPathFromSegments(childPathSegments: Iterable<string>): TItem | undefined;
findLongestPrefixMatch(query: string, delimiter?: string): IPrefixMatch<TItem> | undefined;
static fromJson<TItem extends {}, TSerialized>(json: ILookupByPathJson<TSerialized>, deserializeValue: (serialized: TSerialized) => TItem): LookupByPath<TItem>;
get(key: string, delimiter?: string): TItem | undefined;
getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode<TItem> | undefined;
groupByChild<TInfo>(infoByPath: Map<string, TInfo>, delimiter?: string): Map<TItem, Map<string, TInfo>>;
Expand All @@ -65,6 +80,7 @@ export class LookupByPath<TItem extends {}> implements IReadonlyLookupByPath<TIt
setItem(serializedPath: string, value: TItem, delimiter?: string): this;
setItemFromSegments(pathSegments: Iterable<string>, value: TItem): this;
get size(): number;
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
// (undocumented)
get tree(): IReadonlyPathTrieNode<TItem>;
}
Expand Down
153 changes: 153 additions & 0 deletions libraries/lookup-by-path/src/LookupByPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,53 @@ export interface IReadonlyPathTrieNode<TItem extends {}> {
readonly children: ReadonlyMap<string, IReadonlyPathTrieNode<TItem>> | undefined;
}

/**
* JSON-serializable representation of a node in a {@link LookupByPath} trie.
*
* @beta
*/
export interface ISerializedPathTrieNode {
/**
* Index into the `values` array of the containing {@link ILookupByPathJson}.
* If `undefined`, this node has no associated value.
*/
valueIndex?: number;

/**
* Child nodes keyed by path segment.
*/
children?: Record<string, ISerializedPathTrieNode>;
}

/**
* JSON-serializable representation of a {@link LookupByPath} instance.
*
* @remarks
* The `values` array stores each unique value exactly once (by reference identity).
* Nodes in the tree reference values by their index in this array, which ensures that
* reference equality is preserved across serialization and deserialization.
*
* @beta
*/
export interface ILookupByPathJson<TSerialized> {
/**
* The path delimiter used by the serialized trie.
*/
delimiter: string;

/**
* Array of serialized values. Nodes in the tree reference values by their index in this array.
* Using an array with index-based references preserves reference equality: if multiple nodes
* share the same value (by reference), they will reference the same index.
*/
values: TSerialized[];

/**
* The serialized tree structure.
*/
tree: ISerializedPathTrieNode;
}

interface IPrefixEntry {
/**
* The prefix that was matched
Expand Down Expand Up @@ -195,6 +242,18 @@ export interface IReadonlyLookupByPath<TItem extends {}> extends Iterable<[strin
* @returns The trie node at the specified prefix, or `undefined` if no node was found
*/
getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode<TItem> | undefined;

/**
* Serializes this `LookupByPath` instance to a JSON-compatible representation.
*
* @param serializeValue - A function that converts a value of type `TItem` to a JSON-serializable form.
* @returns A JSON-serializable representation of this trie.
*
* @remarks
* Values that are reference-equal will be serialized once and referenced by index, ensuring
* that reference equality is preserved when deserialized via {@link LookupByPath.fromJson}.
*/
toJson<TSerialized>(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson<TSerialized>;
}

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

/**
* {@inheritdoc IReadonlyLookupByPath.toJson}
*/
public toJson<TSerialized>(
serializeValue: (value: TItem) => TSerialized
): ILookupByPathJson<TSerialized> {
const valueToIndex: Map<TItem, number> = new Map();
Comment thread
iclanton marked this conversation as resolved.
const values: TSerialized[] = [];

const getOrAddValueIndex: (value: TItem) => number = (value: TItem) => {
let index: number | undefined = valueToIndex.get(value);
if (index === undefined) {
index = values.length;
valueToIndex.set(value, index);
values.push(serializeValue(value));
}
return index;
};

const serializeNode: (node: IPathTrieNode<TItem>) => ISerializedPathTrieNode = (
node: IPathTrieNode<TItem>
) => {
const result: ISerializedPathTrieNode = {};

if (node.value !== undefined) {
result.valueIndex = getOrAddValueIndex(node.value);
}

if (node.children && node.children.size > 0) {
const children: Record<string, ISerializedPathTrieNode> = Object.create(null);
for (const [segment, child] of node.children) {
children[segment] = serializeNode(child);
}
result.children = children;
}

return result;
};

return {
delimiter: this.delimiter,
values,
tree: serializeNode(this._root)
};
}

/**
* Deserializes a `LookupByPath` instance from a JSON representation previously
* created by {@link LookupByPath.toJson}.
*
* @param json - The JSON representation to deserialize.
* @param deserializeValue - A function that converts a serialized value back to its original type.
* @returns A new `LookupByPath` instance.
*
* @remarks
* Reference equality is preserved: if multiple nodes in the serialized trie pointed at the same
* value (i.e., the same index in the `values` array), the deserialized nodes will share the same
* object reference.
*/
public static fromJson<TItem extends {}, TSerialized>(
json: ILookupByPathJson<TSerialized>,
deserializeValue: (serialized: TSerialized) => TItem
): LookupByPath<TItem> {
const deserializedValues: TItem[] = json.values.map(deserializeValue);

const result: LookupByPath<TItem> = new LookupByPath<TItem>(undefined, json.delimiter);

const deserializeNode: (
jsonNode: ISerializedPathTrieNode,
targetNode: IPathTrieNode<TItem>
) => void = (jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode<TItem>) => {
if (jsonNode.valueIndex !== undefined) {
targetNode.value = deserializedValues[jsonNode.valueIndex];
result._size++;
}

if (jsonNode.children) {
targetNode.children = new Map();
for (const [segment, childJson] of Object.entries(jsonNode.children)) {
const childNode: IPathTrieNode<TItem> = {
value: undefined,
children: undefined
};
targetNode.children.set(segment, childNode);
deserializeNode(childJson, childNode);
}
}
};

deserializeNode(json.tree, result._root);

return result;
}

/**
* Iterates through progressively longer prefixes of a given string and returns as soon
* as the number of candidate items that match the prefix are 1 or 0.
Expand Down
8 changes: 7 additions & 1 deletion libraries/lookup-by-path/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
* @packageDocumentation
*/

export type { IPrefixMatch, IReadonlyLookupByPath, IReadonlyPathTrieNode } from './LookupByPath';
export type {
ILookupByPathJson,
IPrefixMatch,
IReadonlyLookupByPath,
IReadonlyPathTrieNode,
ISerializedPathTrieNode
} from './LookupByPath';
export { LookupByPath } from './LookupByPath';
export type { IGetFirstDifferenceInCommonNodesOptions } from './getFirstDifferenceInCommonNodes';
export { getFirstDifferenceInCommonNodes } from './getFirstDifferenceInCommonNodes';
Loading
Loading