diff --git a/src/utils/reflection/object-reflector.ts b/src/utils/reflection/object-reflector.ts new file mode 100644 index 0000000..8e20c4f --- /dev/null +++ b/src/utils/reflection/object-reflector.ts @@ -0,0 +1,207 @@ +import { $i, isIterable, KeyValueIterable, isKeyValueIterable, asArray } from "@/utils/collections"; +import { RecordKey, UnionToIntersection } from "@/utils/types"; + +/** + * Defines nested properties on an object. + * + * @template T - The type of the object to define nested properties on. + * + * @param obj - The object to define nested properties on. + * @param properties - A map or iterable of property paths and property descriptors. + * @param factory - An optional factory function for creating property descriptors for nested objects. + * + * @returns The input object with the nested properties defined. + * @throws {TypeError} - If a path tries to define a property on a non-object value, e.g., `boolean`, `number`, etc. + */ +export function defineNestedProperties(obj: T, properties: PropertyDescriptorMap | Iterable, factory?: (obj: unknown, property: PropertyKey) => PropertyDescriptor): T | never { + const iterableProperties = isIterable(properties) ? properties : Object.entries(properties); + for (const [path, descriptor] of iterableProperties) { + defineNestedProperty(obj, path, descriptor, factory); + } + return obj; +} + +/** + * Defines a single nested property on an object using a property descriptor and an optional factory function. + * + * @template T - The type of the object to define the nested property on. + * + * @param obj - The object to define the nested property on. + * @param path - The path of the nested property to define, as a dot-separated string (e.g., "a.b.c") or an array of property keys. + * @param property - The property descriptor for the nested property. + * @param factory - An optional factory function for creating property descriptors for nested objects. + * + * @returns The input object with the nested property defined. + * @throws {TypeError} - If a path tries to define a property on a non-object value, e.g., `boolean`, `number`, etc. + */ +export function defineNestedProperty(obj: T, path: string | readonly PropertyKey[], property: PropertyDescriptor, factory?: (obj: unknown, property: PropertyKey) => PropertyDescriptor): T | never { + path = typeof path === "string" ? path.split(".") : path; + factory ||= () => ({ value: { }, writable: true, configurable: true, enumerable: true }); + + let currentObj = obj as Record; + const depth = path.length - 1; + for (let i = 0; i < depth; ++i) { + const propertyName = path[i]; + const existingValue = currentObj[propertyName]; + if (existingValue === undefined || existingValue === null) { + const nestedDescriptor = factory(currentObj, propertyName); + Object.defineProperty(currentObj, propertyName, nestedDescriptor); + } + currentObj = currentObj[propertyName] as Record; + } + + const name = path[depth]; + Object.defineProperty(currentObj, name, property); + + return obj; +} + +/** + * Returns an iterable of all property descriptors from the given object and its prototypes. + * + * @param obj - The object to get the property descriptors from. + * + * @returns An iterable of key-descriptor pairs. + */ +export function* getAllPropertyDescriptors(obj: unknown): Iterable<[string | symbol, PropertyDescriptor]> { + const visited = new Set(); + + while (obj !== undefined && obj !== null) { + const keys = Array.prototype.concat( + Object.getOwnPropertyNames(obj), + Object.getOwnPropertySymbols(obj) + ); + const descriptors = Object.getOwnPropertyDescriptors(obj); + + for (const key of keys) { + if (!visited.has(key)) { + visited.add(key); + yield [key, descriptors[key]]; + } + } + + obj = Object.getPrototypeOf(obj); + } +} + +/** + * Retrieves a property descriptor from the given object, considering its prototype chain. + * + * @param obj - The object to get the property descriptor from. + * @param key - The property key. + * + * @returns The property descriptor, or `undefined` if not found. + */ +export function getPropertyDescriptor(obj: unknown, key: PropertyKey): PropertyDescriptor { + key = typeof key === "number" ? String(key) : key; + + const ownPropertyDescriptor = Object.getOwnPropertyDescriptor(obj, key); + if (ownPropertyDescriptor) { + return ownPropertyDescriptor; + } + + return $i(getAllPropertyDescriptors(obj)).find(([x]) => x === key)?.[1]; +} + +/** + * Generates an iterable of all keys from the given object and its prototypes. + * + * @param obj - The object to get the keys from. + * + * @returns An iterable of property keys. + */ +export function getAllKeys(obj: unknown): Iterable { + return $i(getAllPropertyDescriptors(obj)).map(([key]) => key); +} + +/** + * Generates an iterable of all string keys from the given object and its prototypes. + * + * @param obj - The object to get the string keys from. + * + * @returns An iterable of string property keys. + */ +export function getAllNames(obj: unknown): Iterable { + return $i(getAllKeys(obj)).filter((key): key is string => typeof key === "string"); +} + +/** + * Generates an iterable of all symbol keys from the given object and its prototypes. + * + * @param obj - The object to get the symbol keys from. + * + * @returns An iterable of symbol property keys. + */ +export function getAllSymbols(obj: unknown): Iterable { + return $i(getAllKeys(obj)).filter((key): key is symbol => typeof key === "symbol"); +} + +/** + * Generates an iterable of all property values from the given object and its prototypes. + * + * @param obj - The object to get the property values from. + * + * @returns An iterable of property values. + */ +export function getAllValues(obj: unknown): Iterable { + return $i(getAllPropertyDescriptors(obj)).map(([key]) => obj[key]); +} + +/** + * Generates an iterable of all entries from the given object and its prototypes. + * + * @param obj - The object to get the entries from. + * + * @returns An iterable of key-value pairs. + */ +export function getAllEntries(obj: unknown): Iterable<[string | symbol, unknown]> { + return $i(getAllPropertyDescriptors(obj)).map(([key]) => [key, obj[key]]); +} + +/** + * Retrieves the key-value pairs from an object. + * + * @template K - The key type. + * @template V - The value type. + * + * @param obj - The object to extract key-value pairs from. + * + * @returns An iterable containing the key-value pairs. + */ +export function getOwnEntries(obj: KeyValueIterable | Iterable | Record, V>): Iterable<[K, V]> { + if (!obj) { + return []; + } + + if (isKeyValueIterable(obj)) { + return obj.entries(); + } + + if (isIterable<[unknown, unknown]>(obj)) { + const entries = asArray(obj); + if (entries.every(x => Array.isArray(x))) { + return entries as Iterable<[K, V]>; + } + } + + return Object.entries(obj) as Iterable<[K, V]>; +} + +/** + * Merges multiple objects into a single object while preserving property descriptors. + * If a property exists in multiple objects, the last object's descriptor takes precedence. + * + * @template T - A tuple of objects to be merged. + * + * @param values - The objects to be merged. + * + * @returns A single object resulting from the merge of input objects. + */ +export function merge(...values: T): UnionToIntersection { + const result = { } as UnionToIntersection; + const descriptors = $i(values).flatMap(x => getAllPropertyDescriptors(x)); + for (const [property, descriptor] of descriptors) { + Object.defineProperty(result, property, descriptor); + } + return result; +} diff --git a/tests/unit/utils/reflection/object-reflector.spec.ts b/tests/unit/utils/reflection/object-reflector.spec.ts new file mode 100644 index 0000000..286a7b8 --- /dev/null +++ b/tests/unit/utils/reflection/object-reflector.spec.ts @@ -0,0 +1,197 @@ +import { + defineNestedProperties, + defineNestedProperty, + getAllEntries, + getAllKeys, + getAllNames, + getAllPropertyDescriptors, + getAllSymbols, + getAllValues, + getOwnEntries, + getPropertyDescriptor, + merge, +} from "@/utils/reflection/object-reflector"; + +describe("defineNestedProperties", () => { + test("defines properties for the given object", () => { + const properties = { + "a.b.c": { value: 1, writable: true }, + "a.b.d": { value: 2, writable: true }, + "a.c": { value: 3, writable: true }, + "b": { value: 4, writable: true }, + }; + + const result = defineNestedProperties({}, properties); + + expect(result).toHaveProperty("a.b.c", 1); + expect(result).toHaveProperty("a.b.d", 2); + expect(result).toHaveProperty("a.c", 3); + expect(result).toHaveProperty("b", 4); + }); + + test("throws TypeError for non-object value", () => { + const properties = { "a.b.c": { value: 1, writable: true } }; + + expect(() => defineNestedProperties(1, properties)).toThrow(TypeError); + }); +}); + +describe("defineNestedProperty", () => { + test("defines a property for the given object", () => { + const property = { value: 1, writable: true }; + + const result = defineNestedProperty({}, "a.b.c", property); + + expect(result).toHaveProperty("a.b.c", 1); + }); + + test("throws TypeError for non-object value", () => { + const property = { value: 1, writable: true }; + + expect(() => defineNestedProperty(1, "a.b.c", property)).toThrow(TypeError); + }); +}); + +describe("getAllPropertyDescriptors", () => { + test("returns all property descriptors from the given object and its prototypes", () => { + const obj = { a: 1 }; + + const result = Array.from(getAllPropertyDescriptors(obj)); + const keys = result.map(([key]) => key); + + expect(keys).toContain("a"); + expect(keys).toContain("toString"); + expect(keys).toContain("constructor"); + }); +}); + +describe("getPropertyDescriptor", () => { + test("returns the property descriptor of the given object", () => { + expect(getPropertyDescriptor({ a: 1 }, "a")).toBeDefined(); + expect(getPropertyDescriptor({}, "toString")).toBeDefined(); + }); + + test("returns undefined if property descriptor is not found", () => { + expect(getPropertyDescriptor({ a: 1 }, "b")).toBeUndefined(); + expect(getPropertyDescriptor({}, "toJSON")).toBeUndefined(); + }); +}); + +describe("getAllKeys", () => { + test("returns all keys from the given object and its prototypes", () => { + const obj = { a: 1, b: 2, [Symbol.toStringTag]: "3" }; + + const keys = Array.from(getAllKeys(obj)); + + expect(keys).toEqual(expect.arrayContaining(["a", "b", Symbol.toStringTag, "toString", "constructor"])); + }); +}); + +describe("getAllNames", () => { + test("returns all string keys from the given object and its prototypes", () => { + const obj = { a: 1, b: 2, [Symbol.toStringTag]: "3" }; + + const names = Array.from(getAllNames(obj)); + + expect(names).toEqual(expect.arrayContaining(["a", "b", "toString", "constructor"])); + }); +}); + +describe("getAllSymbols", () => { + test("returns all symbol keys from the given object and its prototypes", () => { + const obj = { a: 1, b: 2, [Symbol.toStringTag]: "3" }; + + const symbols = Array.from(getAllSymbols(obj)); + + expect(symbols).toEqual(expect.arrayContaining([Symbol.toStringTag])); + }); +}); + +describe("getAllValues", () => { + test("returns all property values from the given object and its prototypes", () => { + const obj = { a: 1, b: 2, [Symbol.toStringTag]: "3" }; + + const values = Array.from(getAllValues(obj)); + + expect(values).toEqual(expect.arrayContaining([1, 2, "3", Object.prototype.constructor, Object.prototype.toString])); + }); +}); + +describe("getAllEntries", () => { + test("returns all entries from the given object and its prototypes", () => { + const obj = { a: 1, b: 2, [Symbol.toStringTag]: "3" }; + + const entries = Array.from(getAllEntries(obj)); + + expect(entries).toEqual(expect.arrayContaining([ + ["a", 1], + ["b", 2], + [Symbol.toStringTag, "3"], + ["toString", Object.prototype.toString], + ["constructor", Object.prototype.constructor], + ])); + }); +}); + +describe("getOwnEntries", () => { + test("returns the key-value pairs from an object", () => { + const obj = { a: 1, b: 2 }; + + const result = Array.from(getOwnEntries(obj)); + + expect(result).toEqual([["a", 1], ["b", 2]]); + }); + + test("returns the key-value pairs from a map", () => { + const map = new Map(Object.entries({ a: 1, b: 2 })); + + const result = Array.from(getOwnEntries(map)); + + expect(result).toEqual([["a", 1], ["b", 2]]); + }); + + test("returns empty array if the object is null or undefined", () => { + expect(Array.from(getOwnEntries(null))).toEqual([]); + expect(Array.from(getOwnEntries(undefined))).toEqual([]); + }); +}); + +describe("merge", () => { + test("merges multiple objects into a single object while preserving property descriptors", () => { + const obj1 = { a: 1, b: 2 }; + const obj2 = { c: 3, d: 4 }; + + const merged = merge(obj1, obj2); + + expect(merged).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); + + test("respects precedence when merging objects", () => { + const obj1 = { a: 1, b: 2 }; + const obj2 = { b: 3, c: 4 }; + + const merged = merge(obj1, obj2); + + expect(merged).toEqual({ a: 1, b: 3, c: 4 }); + expect(Object.getOwnPropertyDescriptor(obj2, "b")).toStrictEqual(Object.getOwnPropertyDescriptor(merged, "b")); + }); + + test("preserves getters and setters when merging objects", () => { + const obj1 = { + _a: 1, + get a() { return this._a; }, + set a(val) { this._a = val; }, + }; + const obj2 = { + _b: 2, + get b() { return this._b; }, + set b(val) { this._b = val; } + }; + + const merged = merge(obj1, obj2); + + expect(merged).toMatchObject({ a: 1, b: 2 }); + expect(Object.getOwnPropertyDescriptor(merged, "a")).toEqual(Object.getOwnPropertyDescriptor(obj1, "a")); + expect(Object.getOwnPropertyDescriptor(merged, "b")).toEqual(Object.getOwnPropertyDescriptor(obj2, "b")); + }); +});