diff --git a/common/changes/@rushstack/lookup-by-path/enhance-lookup-by-path-json-methods_2026-04-07-22-32.json b/common/changes/@rushstack/lookup-by-path/enhance-lookup-by-path-json-methods_2026-04-07-22-32.json new file mode 100644 index 00000000000..b3e2d220c5f --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/enhance-lookup-by-path-json-methods_2026-04-07-22-32.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add `toJson`/`fromJson` methods to `LookupByPath` for serialization/deserialization", + "type": "minor", + "packageName": "@rushstack/lookup-by-path" + } + ], + "packageName": "@rushstack/lookup-by-path", + "email": "198982749+Copilot@users.noreply.github.com" +} diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index 66336af9e63..dec5eefc235 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -16,6 +16,13 @@ export interface IGetFirstDifferenceInCommonNodesOptions { second: IReadonlyPathTrieNode; } +// @beta +export interface ILookupByPathJson { + delimiter: string; + tree: ISerializedPathTrieNode; + values: TSerialized[]; +} + // @beta export interface IPrefixMatch { index: number; @@ -35,6 +42,7 @@ export interface IReadonlyLookupByPath extends Iterable<[strin groupByChild(infoByPath: Map, delimiter?: string): Map>; has(query: string, delimiter?: string): boolean; get size(): number; + toJson(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson; // (undocumented) get tree(): IReadonlyPathTrieNode; } @@ -45,6 +53,12 @@ export interface IReadonlyPathTrieNode { readonly value: TItem | undefined; } +// @beta +export interface ISerializedPathTrieNode { + children?: Record; + valueIndex?: number; +} + // @beta export class LookupByPath implements IReadonlyLookupByPath { [Symbol.iterator](query?: string, delimiter?: string): IterableIterator<[string, TItem]>; @@ -57,6 +71,7 @@ export class LookupByPath implements IReadonlyLookupByPath): TItem | undefined; findLongestPrefixMatch(query: string, delimiter?: string): IPrefixMatch | undefined; + static fromJson(json: ILookupByPathJson, deserializeValue: (serialized: TSerialized) => TItem): LookupByPath; get(key: string, delimiter?: string): TItem | undefined; getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode | undefined; groupByChild(infoByPath: Map, delimiter?: string): Map>; @@ -65,6 +80,7 @@ export class LookupByPath implements IReadonlyLookupByPath, value: TItem): this; get size(): number; + toJson(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson; // (undocumented) get tree(): IReadonlyPathTrieNode; } diff --git a/libraries/lookup-by-path/src/LookupByPath.ts b/libraries/lookup-by-path/src/LookupByPath.ts index a6b3353371e..0a524a6d7fb 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -36,6 +36,53 @@ export interface IReadonlyPathTrieNode { readonly children: ReadonlyMap> | 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; +} + +/** + * 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 { + /** + * 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 @@ -195,6 +242,18 @@ export interface IReadonlyLookupByPath extends Iterable<[strin * @returns The trie node at the specified prefix, or `undefined` if no node was found */ getNodeAtPrefix(query: string, delimiter?: string): IReadonlyPathTrieNode | 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(serializeValue: (value: TItem) => TSerialized): ILookupByPathJson; } /** @@ -545,6 +604,100 @@ export class LookupByPath implements IReadonlyLookupByPath( + serializeValue: (value: TItem) => TSerialized + ): ILookupByPathJson { + const valueToIndex: Map = new Map(); + 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) => ISerializedPathTrieNode = ( + node: IPathTrieNode + ) => { + const result: ISerializedPathTrieNode = {}; + + if (node.value !== undefined) { + result.valueIndex = getOrAddValueIndex(node.value); + } + + if (node.children && node.children.size > 0) { + const children: Record = 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( + json: ILookupByPathJson, + deserializeValue: (serialized: TSerialized) => TItem + ): LookupByPath { + const deserializedValues: TItem[] = json.values.map(deserializeValue); + + const result: LookupByPath = new LookupByPath(undefined, json.delimiter); + + const deserializeNode: ( + jsonNode: ISerializedPathTrieNode, + targetNode: IPathTrieNode + ) => void = (jsonNode: ISerializedPathTrieNode, targetNode: IPathTrieNode) => { + 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 = { + 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. diff --git a/libraries/lookup-by-path/src/index.ts b/libraries/lookup-by-path/src/index.ts index c91d2692ef9..25fbc6b065f 100644 --- a/libraries/lookup-by-path/src/index.ts +++ b/libraries/lookup-by-path/src/index.ts @@ -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'; diff --git a/libraries/lookup-by-path/src/test/LookupByPath.test.ts b/libraries/lookup-by-path/src/test/LookupByPath.test.ts index d785252dccb..2b8e8beeb65 100644 --- a/libraries/lookup-by-path/src/test/LookupByPath.test.ts +++ b/libraries/lookup-by-path/src/test/LookupByPath.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { LookupByPath } from '../LookupByPath'; +import type { ILookupByPathJson } from '../LookupByPath'; describe(LookupByPath.iteratePathSegments.name, () => { it('returns empty for an empty string', () => { @@ -673,3 +674,242 @@ describe(LookupByPath.prototype.groupByChild.name, () => { expect(falsyLookup.groupByChild(infoByPath)).toEqual(expected); }); }); + +describe(`${LookupByPath.prototype.toJson.name} and ${LookupByPath.fromJson.name}`, () => { + it('round-trips an empty trie', () => { + const original = new LookupByPath(); + const json: ILookupByPathJson = original.toJson((v) => v); + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.size).toEqual(0); + expect([...restored]).toEqual([]); + }); + + it('round-trips with number values', () => { + const original = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['baz', 3] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.size).toEqual(3); + expect(restored.get('foo')).toEqual(1); + expect(restored.get('foo/bar')).toEqual(2); + expect(restored.get('baz')).toEqual(3); + expect(restored.get('missing')).toEqual(undefined); + }); + + it('snapshot: serialized JSON for a simple tree', () => { + const original = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['baz', 3] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + expect(json).toMatchSnapshot(); + }); + + it('snapshot: serialized JSON with intermediate nodes and custom delimiter', () => { + const original = new LookupByPath( + [ + ['a,b,c', 'deep'], + ['a,b', 'mid'], + ['x', 'top'] + ], + ',' + ); + + const json: ILookupByPathJson = original.toJson((v) => v); + expect(json).toMatchSnapshot(); + }); + + it('round-trips with string values', () => { + const original = new LookupByPath([ + ['a', 'alpha'], + ['a/b', 'bravo'], + ['c', 'charlie'] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.size).toEqual(3); + expect(restored.get('a')).toEqual('alpha'); + expect(restored.get('a/b')).toEqual('bravo'); + expect(restored.get('c')).toEqual('charlie'); + }); + + it('preserves reference equality for shared values', () => { + const sharedObj = { name: 'shared' }; + const original = new LookupByPath<{ name: string }>([ + ['foo', sharedObj], + ['bar', sharedObj], + ['baz/qux', sharedObj] + ]); + + const json: ILookupByPathJson<{ name: string }> = original.toJson((v) => ({ ...v })); + // All three entries should point at the same index + expect(json.values.length).toEqual(1); + expect(json.values[0]).toEqual({ name: 'shared' }); + + const restored: LookupByPath<{ name: string }> = LookupByPath.fromJson(json, (v) => ({ ...v })); + + expect(restored.size).toEqual(3); + const fooVal = restored.get('foo'); + const barVal = restored.get('bar'); + const bazQuxVal = restored.get('baz/qux'); + + // All deserialized values should be the same reference + expect(fooVal).toBe(barVal); + expect(barVal).toBe(bazQuxVal); + expect(fooVal).toEqual({ name: 'shared' }); + }); + + it('keeps non-reference-equal objects with same JSON as separate entries', () => { + const obj1 = { name: 'same' }; + const obj2 = { name: 'same' }; + // Verify they are not reference-equal + expect(obj1).not.toBe(obj2); + + const original = new LookupByPath<{ name: string }>([ + ['foo', obj1], + ['bar', obj2] + ]); + + const json: ILookupByPathJson<{ name: string }> = original.toJson((v) => ({ ...v })); + // Should have two separate entries even though the JSON is the same + expect(json.values.length).toEqual(2); + + const restored: LookupByPath<{ name: string }> = LookupByPath.fromJson(json, (v) => ({ ...v })); + + expect(restored.size).toEqual(2); + const fooVal = restored.get('foo'); + const barVal = restored.get('bar'); + + // Values should be structurally equal + expect(fooVal).toEqual({ name: 'same' }); + expect(barVal).toEqual({ name: 'same' }); + + // But NOT reference-equal + expect(fooVal).not.toBe(barVal); + }); + + it('round-trips a complex multi-level tree', () => { + const original = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['foo/bar/baz', 3], + ['foo/bar/baz/qux', 4], + ['bar', 5], + ['bar/baz', 6] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.size).toEqual(original.size); + for (const [path, value] of original) { + expect(restored.get(path)).toEqual(value); + } + }); + + it('round-trips with a custom delimiter', () => { + const original = new LookupByPath( + [ + ['foo,bar', 1], + ['foo,bar,baz', 2], + ['qux', 3] + ], + ',' + ); + + const json: ILookupByPathJson = original.toJson((v) => v); + expect(json.delimiter).toEqual(','); + + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.delimiter).toEqual(','); + expect(restored.size).toEqual(3); + expect(restored.get('foo,bar')).toEqual(1); + expect(restored.get('foo,bar,baz')).toEqual(2); + expect(restored.get('qux')).toEqual(3); + }); + + it('uses custom serializer and deserializer', () => { + const original = new LookupByPath<{ id: number; label: string }>([ + ['a', { id: 1, label: 'one' }], + ['b', { id: 2, label: 'two' }] + ]); + + const json: ILookupByPathJson = original.toJson((v) => JSON.stringify(v)); + expect(json.values).toEqual(['{"id":1,"label":"one"}', '{"id":2,"label":"two"}']); + + const restored: LookupByPath<{ id: number; label: string }> = LookupByPath.fromJson( + json, + (v) => JSON.parse(v) as { id: number; label: string } + ); + + expect(restored.size).toEqual(2); + expect(restored.get('a')).toEqual({ id: 1, label: 'one' }); + expect(restored.get('b')).toEqual({ id: 2, label: 'two' }); + }); + + it('produces valid JSON for the serialized form', () => { + const original = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + const jsonString: string = JSON.stringify(json); + const parsed: ILookupByPathJson = JSON.parse(jsonString) as ILookupByPathJson; + + const restored: LookupByPath = LookupByPath.fromJson(parsed, (v) => v); + expect(restored.size).toEqual(2); + expect(restored.get('foo')).toEqual(1); + expect(restored.get('foo/bar')).toEqual(2); + }); + + it('preserves findChildPath behavior after round-trip', () => { + const original = new LookupByPath([ + ['foo', 1], + ['foo/bar', 2], + ['baz', 3] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + + expect(restored.findChildPath('foo/baz')).toEqual(1); + expect(restored.findChildPath('foo/bar/baz')).toEqual(2); + expect(restored.findChildPath('baz/anything')).toEqual(3); + expect(restored.findChildPath('missing')).toEqual(undefined); + }); + + it('handles nodes with children but no value', () => { + const original = new LookupByPath([ + ['foo/bar/baz', 1], + ['foo/bar/qux', 2] + ]); + + const json: ILookupByPathJson = original.toJson((v) => v); + // The intermediate nodes 'foo' and 'foo/bar' should exist in the tree but have no valueIndex + const fooNode = json.tree.children!.foo; + const barNode = fooNode.children!.bar; + expect(fooNode.valueIndex).toBeUndefined(); + expect(barNode.valueIndex).toBeUndefined(); + expect(barNode.children!.baz.valueIndex).toEqual(0); + expect(barNode.children!.qux.valueIndex).toEqual(1); + + const restored: LookupByPath = LookupByPath.fromJson(json, (v) => v); + expect(restored.size).toEqual(2); + expect(restored.get('foo')).toEqual(undefined); + expect(restored.get('foo/bar')).toEqual(undefined); + expect(restored.get('foo/bar/baz')).toEqual(1); + expect(restored.get('foo/bar/qux')).toEqual(2); + }); +}); diff --git a/libraries/lookup-by-path/src/test/__snapshots__/LookupByPath.test.ts.snap b/libraries/lookup-by-path/src/test/__snapshots__/LookupByPath.test.ts.snap new file mode 100644 index 00000000000..bf63c46b8d6 --- /dev/null +++ b/libraries/lookup-by-path/src/test/__snapshots__/LookupByPath.test.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toJson and fromJson snapshot: serialized JSON for a simple tree 1`] = ` +Object { + "delimiter": "/", + "tree": Object { + "children": Object { + "baz": Object { + "valueIndex": 2, + }, + "foo": Object { + "children": Object { + "bar": Object { + "valueIndex": 1, + }, + }, + "valueIndex": 0, + }, + }, + }, + "values": Array [ + 1, + 2, + 3, + ], +} +`; + +exports[`toJson and fromJson snapshot: serialized JSON with intermediate nodes and custom delimiter 1`] = ` +Object { + "delimiter": ",", + "tree": Object { + "children": Object { + "a": Object { + "children": Object { + "b": Object { + "children": Object { + "c": Object { + "valueIndex": 1, + }, + }, + "valueIndex": 0, + }, + }, + }, + "x": Object { + "valueIndex": 2, + }, + }, + }, + "values": Array [ + "mid", + "deep", + "top", + ], +} +`;