UNPKG

@nesvet/n

Version:
258 lines 10.8 kB
/* eslint-disable guard-for-in, @typescript-eslint/no-explicit-any */ /** * A class for projecting objects in MongoDB style. * Supports field inclusion/exclusion based on a fixed projection. */ export class Projector { /** * Creates a new MongoProjector instance with a fixed projection. * @param projection The projection object (keys are paths, values are 0 or 1). */ constructor(projection) { if (projection === null || typeof projection !== "object") throw new Error("Projection must be a non-null object"); this.#projection = { ...projection }; this.#isInclude = this.#isIncludeProjection(this.#projection); if (!this.#isInclude) this.#trie = this.#buildTrie(Object.keys(this.#projection)); } #projection; #isInclude; #trie = null; #pathCache = new Map(); #maxPathCacheSize = 10000; #maxDepth = 100; /** * Clears the path cache. */ clearPathCache() { this.#pathCache.clear(); } /** * Applies the projection to an object, returning a new object with selected fields. * @param obj The input object. * @param strict If true, throws errors for inaccessible paths. * @returns The projected object. * @throws Error If the input object is null or not an object, or if strict mode detects issues. */ project(obj, strict = false) { if (obj === null || typeof obj !== "object") throw new Error("Input object must be a non-null object"); if (Object.keys(this.#projection).length === 0) return this.#deepCopy(obj, new WeakSet()); const result = Object.create(null); if (this.#isInclude) { for (const key in this.#projection) { if (this.#projection[key] !== 1) continue; const value = this.#getNestedValue(obj, key, strict); if (value !== undefined) this.#setNestedValue(result, key, value, strict); else if (strict) throw new Error(`Path "${key}" not found in object: ${JSON.stringify(obj, null, 2)}`); } if (!("_id" in this.#projection) && "_id" in obj) result._id = obj._id; } else this.#deepCopyExcluding(obj, result, [], this.#trie); return result; } /** * Applies the projection to an array of objects. * @param docs The array of input objects. * @param strict If true, throws errors for inaccessible paths. * @returns An array of projected objects. * @throws Error If the input is not an array. */ projectMany(docs, strict = false) { if (!Array.isArray(docs)) throw new Error("Input must be an array of objects"); return docs.map(doc => this.project(doc, strict)); } /** * Checks if the projection is an inclusion projection. * @param projection The projection object. * @returns True if the projection includes at least one field (value 1). * @private */ #isIncludeProjection(projection) { return Object.values(projection).includes(1); } /** * Creates a deep copy of an object, detecting circular references. * @param obj The input object. * @param seen A WeakSet to track visited objects. * @returns The copied object. * @throws Error If a circular reference is detected. * @private */ #deepCopy(obj, seen) { if (obj === null || typeof obj !== "object") return obj; if (seen.has(obj)) throw new Error("Circular reference detected in object"); seen.add(obj); if (Array.isArray(obj)) return obj.map(item => this.#deepCopy(item, seen)); const result = Object.create(null); for (const key in obj) result[key] = this.#deepCopy(obj[key], seen); return result; } /** * Retrieves a value from an object by path. * @param obj The input object. * @param path The dot-separated path (e.g., "user.address.city"). * @param strict If true, throws errors for inaccessible paths. * @returns The value at the path, or undefined if not found. * @throws Error If strict mode is enabled and the path is invalid or depth limit is exceeded. * @private */ #getNestedValue(obj, path, strict) { const keys = this.#pathCache.get(path) ?? this.#parsePath(path); this.#pathCache.set(path, keys); if (this.#pathCache.size > this.#maxPathCacheSize) { const oldestKey = this.#pathCache.keys().next().value; this.#pathCache.delete(oldestKey); } let current = obj; let depth = 0; for (const key of keys) { if (depth++ > this.#maxDepth) throw new Error(`Maximum object depth exceeded at path "${path}"`); if (current === null || typeof current !== "object") { if (strict) throw new Error(`Expected object at "${key}" in path "${path}": ${JSON.stringify(current)}`); return undefined; } current = current[key]; } return current; } /** * Sets a value in an object by path, creating intermediate objects if needed. * @param obj The target object. * @param path The dot-separated path. * @param value The value to set. * @param strict If true, throws errors for invalid paths. * @throws Error If strict mode is enabled and the path is invalid or depth limit is exceeded. * @private */ #setNestedValue(obj, path, value, strict) { const keys = this.#pathCache.get(path) ?? this.#parsePath(path); this.#pathCache.set(path, keys); if (this.#pathCache.size > this.#maxPathCacheSize) { const oldestKey = this.#pathCache.keys().next().value; this.#pathCache.delete(oldestKey); } let current = obj; let depth = 0; for (let i = 0; i < keys.length - 1; i++) { if (depth++ > this.#maxDepth) throw new Error(`Maximum object depth exceeded at path "${path}"`); const key = keys[i]; if (typeof current[key] !== "object" || current[key] === null) { if (strict && current[key] !== undefined) throw new Error(`Expected object at "${key}" in path "${path}": ${JSON.stringify(current[key])}`); current[key] = Object.create(null); } current = current[key]; } current[keys.at(-1)] = value; } /** * Copies an object, excluding specified paths, using an iterative approach. * @param src The source object. * @param dest The destination object. * @param path The current path as an array of keys. * @param trie The trie for excluded paths. * @throws Error If a circular reference or depth limit is exceeded. * @private */ #deepCopyExcluding(src, dest, path, trie) { if (src === null || typeof src !== "object") return; const seen = new WeakSet(); const stack = [{ src, dest, path, node: trie, depth: 0 }]; while (stack.length > 0) { const { src: currentSrc, dest: currentDest, path: currentPath, node: currentNode, depth } = stack.pop(); if (depth > this.#maxDepth) throw new Error(`Maximum object depth exceeded at path "${currentPath.join(".")}"`); if (currentSrc === null || typeof currentSrc !== "object") continue; if (seen.has(currentSrc)) throw new Error(`Circular reference detected at path "${currentPath.join(".")}"`); seen.add(currentSrc); for (const key in currentSrc) { const nextNode = currentNode.children.get(key); if (nextNode?.isExcluded) continue; const srcVal = currentSrc[key]; if (Array.isArray(srcVal)) currentDest[key] = srcVal.map((item) => this.#deepCopy(item, seen)); else if (typeof srcVal === "object" && srcVal !== null) { const childDest = Object.create(null); if (!nextNode || nextNode.children.size === 0) { Object.assign(childDest, this.#deepCopy(srcVal, seen)); if (Object.keys(childDest).length > 0) currentDest[key] = childDest; } else { stack.push({ src: srcVal, dest: childDest, path: [...currentPath, key], node: nextNode ?? { children: new Map(), isExcluded: false }, depth: depth + 1 }); if (Object.keys(childDest).length > 0) currentDest[key] = childDest; } } else currentDest[key] = srcVal; } } } /** * Parses a dot-separated path into an array of keys. * @param path The dot-separated path. * @returns An array of path segments. * @throws Error If the path is invalid (empty, contains brackets, or has empty segments). * @private */ #parsePath(path) { if (!path || path.startsWith(".") || path.endsWith(".")) throw new Error(`Invalid MongoDB projection path: "${path}"`); const parts = path.split("."); for (const part of parts) { if (part === "") throw new Error(`Invalid MongoDB projection path: "${path}"`); if (part.includes("[") || part.includes("]")) throw new Error(`Square brackets are not allowed in MongoDB projection path: "${path}"`); } return parts; } /** * Builds a trie for excluded paths. * @param paths An array of paths to exclude. * @returns The root trie node. * @private */ #buildTrie(paths) { const root = { children: new Map(), isExcluded: false }; for (const path of paths) { const keys = this.#parsePath(path); let current = root; for (const key of keys) { if (!current.children.has(key)) current.children.set(key, { children: new Map(), isExcluded: false }); current = current.children.get(key); } current.isExcluded = true; } return root; } } //# sourceMappingURL=Projector.js.map