mirror of
https://github.com/Kir-Antipov/mc-publish.git
synced 2024-11-25 09:51:01 -05:00
Implemented more reflection-specific helpers
This commit is contained in:
parent
919bc5d68e
commit
fd2975b30b
2 changed files with 404 additions and 0 deletions
207
src/utils/reflection/object-reflector.ts
Normal file
207
src/utils/reflection/object-reflector.ts
Normal file
|
@ -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<T>(obj: T, properties: PropertyDescriptorMap | Iterable<readonly [string | readonly PropertyKey[], PropertyDescriptor]>, 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<T>(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<PropertyKey, unknown>;
|
||||
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<PropertyKey, unknown>;
|
||||
}
|
||||
|
||||
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<string | symbol>();
|
||||
|
||||
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<string | symbol> {
|
||||
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<string> {
|
||||
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<symbol> {
|
||||
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<unknown> {
|
||||
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<K, V>(obj: KeyValueIterable<K, V> | Iterable<readonly [K, V]> | Record<RecordKey<K>, 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<T extends unknown[]>(...values: T): UnionToIntersection<T[number]> {
|
||||
const result = { } as UnionToIntersection<T[number]>;
|
||||
const descriptors = $i(values).flatMap(x => getAllPropertyDescriptors(x));
|
||||
for (const [property, descriptor] of descriptors) {
|
||||
Object.defineProperty(result, property, descriptor);
|
||||
}
|
||||
return result;
|
||||
}
|
197
tests/unit/utils/reflection/object-reflector.spec.ts
Normal file
197
tests/unit/utils/reflection/object-reflector.spec.ts
Normal file
|
@ -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"));
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue