diff --git a/src/object/nested.test.ts b/src/object/nested.test.ts index 907c8ac..cb56351 100644 --- a/src/object/nested.test.ts +++ b/src/object/nested.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { getNestedValue, setNestedValue } from "./nested"; +import { findObjectByKeyValues, findObjectPath, getNestedValue, setNestedValue } from "./nested"; describe("getNestedValue", () => { const testObj = { @@ -170,3 +170,178 @@ describe("setNestedValue", () => { expect(obj.list[0]).toBe("item"); }); }); + + + +describe("findObjectPath", () => { + const testObj = { + user: { + name: "John", + id: 1, + contacts: [ + { id: 101, email: "john@example.com", phone: "123-456" }, + { id: 102, email: "john.alt@example.com", phone: "789-012" }, + ], + settings: { + id: 201, + notifications: { + email: true, + push: false, + }, + }, + }, + meta: { + created: "2023-01-01", + tags: ["important", "user"], + }, + items: [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + { id: 1, name: "Item 3" }, // Duplicate ID + ], + config: { + defaultUser: { id: 1 } + } + }; + + test("should find simple property matches", () => { + const paths = findObjectPath(testObj, { id: 1 }); + expect(paths).toContain("user"); + expect(paths).toContain("items[0]"); + expect(paths).toContain("items[2]"); + expect(paths).toContain("config.defaultUser"); + }); + + test("should find nested property matches", () => { + const paths = findObjectPath(testObj, { email: true }); + expect(paths).toContain("user.settings.notifications"); + }); + + test("should find matches in arrays", () => { + const paths = findObjectPath(testObj, { id: 101 }); + expect(paths).toContain("user.contacts[0]"); + + const paths2 = findObjectPath(testObj, { id: 102 }); + expect(paths2).toContain("user.contacts[1]"); + }); + + test("should find matches with multiple criteria", () => { + const paths = findObjectPath(testObj, { id: 1, name: "Item 1" }); + expect(paths).toContain("items[0]"); + expect(paths).not.toContain("items[2]"); // Has id: 1 but different name + expect(paths).not.toContain("user"); // Has id: 1 but no name property + }); + + test("should return empty array for no matches", () => { + const paths = findObjectPath(testObj, { id: 999 }); + expect(paths).toEqual([]); + }); + + test("should handle empty criteria", () => { + const paths = findObjectPath(testObj, {}); + expect(paths).toEqual([]); + }); + + test("should handle null or undefined input", () => { + expect(findObjectPath(null, { id: 1 })).toEqual([]); + expect(findObjectPath(undefined, { id: 1 })).toEqual([]); + expect(findObjectPath(testObj, null)).toEqual([]); + expect(findObjectPath(testObj, undefined)).toEqual([]); + }); + + test("should work with setNestedValue", () => { + const obj = { ...testObj }; // Clone to avoid modifying test object + const paths = findObjectPath(obj, { id: 101 }); + expect(paths.length).toBeGreaterThan(0); + + const path = paths[0]; + const originalValue = getNestedValue(path, obj); + const updatedValue = { ...originalValue, email: "updated@example.com" }; + + setNestedValue(path, updatedValue, obj); + expect(getNestedValue(`${path}.email`, obj)).toBe("updated@example.com"); + }); + + test("should find path to primitive array values", () => { + const paths = findObjectPath(testObj, { 0: "important" }); + expect(paths).toContain("meta.tags"); + }); +}); + +// Only add these tests if you're implementing the advanced version +describe("findObjectByKeyValues", () => { + const testObj = { + user: { + name: "John", + id: 1, + contacts: [ + { id: 101, email: "john@example.com", phone: "123-456" }, + { id: 102, email: "JOHN.alt@example.com", phone: "789-012" }, + ], + }, + items: [ + { id: 1, name: "Item 1", status: "active" }, + { id: 2, name: "Item 2", status: "inactive" }, + { id: 3, name: "Item 3", status: "active" }, + ], + }; + + test("should handle partial matches", () => { + const paths = findObjectByKeyValues(testObj, { id: 1, name: "Unknown" }, { partialMatch: true }); + expect(paths).toContain("user"); + expect(paths).toContain("items[0]"); + }); + + test("should handle maxDepth option", () => { + // Should not find matches beyond depth 1 + const paths = findObjectByKeyValues(testObj, { id: 101 }, { maxDepth: 1 }); + expect(paths).toEqual([]); + + // Should not find matches at depth 2 + const paths2 = findObjectByKeyValues(testObj, { id: 101 }, { maxDepth: 3 }); + expect(paths2).toContain("user.contacts[0]"); + }); + + test("should handle returnFirstMatch option", () => { + // Should return only the first match + const paths = findObjectByKeyValues(testObj, { status: "active" }, { returnFirstMatch: true }); + expect(paths.length).toBe(1); + expect(paths[0]).toBe("items[0]"); + }); + + test("should handle case insensitive matching", () => { + // Should match regardless of case + const paths = findObjectByKeyValues(testObj, { email: "john.alt@example.com" }, { caseSensitive: false }); + expect(paths).toContain("user.contacts[1]"); + }); + + test("should use custom comparison function", () => { + // Custom comparator that checks if string contains substring + const customCompare = (a: any, b: any) => { + if (typeof a === 'string' && typeof b === 'string') { + return a.includes(b); + } + return a === b; + }; + + const paths = findObjectByKeyValues(testObj, { email: "@example" }, { customCompare }); + expect(paths).toContain("user.contacts[0]"); + expect(paths).toContain("user.contacts[1]"); + }); + + test("should combine multiple options", () => { + const paths = findObjectByKeyValues( + testObj, + { id: 1, status: "active" }, + { + partialMatch: true, + maxDepth: 2, + returnFirstMatch: true + } + ); + + expect(paths.length).toBe(1); + // Either user or items[0] could be returned since we're stopping at first match + expect(paths[0] === "user" || paths[0] === "items[0]").toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/object/nested.ts b/src/object/nested.ts index 4045546..95abc32 100644 --- a/src/object/nested.ts +++ b/src/object/nested.ts @@ -102,3 +102,176 @@ export function setNestedValue( current[lastKey] = value; return obj; } +export function findObjectByKeyValues( + obj: Record, + criteria: Record, + options: { + partialMatch?: boolean; + maxDepth?: number; + returnFirstMatch?: boolean; + caseSensitive?: boolean; + customCompare?: (a: any, b: any) => boolean; + } = {} +): string[] { + // Handle invalid inputs + if (!obj || typeof obj !== 'object') { + return []; + } + + // Handle empty criteria + if (!criteria || typeof criteria !== 'object' || Object.keys(criteria).length === 0) { + return []; + } + + const { + partialMatch = false, + maxDepth = Infinity, + returnFirstMatch = false, + caseSensitive = true, + customCompare + } = options; + + const results: string[] = []; + + // Helper function for comparing values + const compareValues = (a: any, b: any): boolean => { + if (customCompare) { + return customCompare(a, b); + } + + // Handle string comparison with case sensitivity option + if (typeof a === 'string' && typeof b === 'string' && !caseSensitive) { + return a.toLowerCase() === b.toLowerCase(); + } + + return a === b; + }; + + // Recursive search function + const search = ( + currentObj: Record, + currentPath: string = '', + depth: number = 0 + ): void => { + // Check depth first - don't process objects beyond max depth + if (depth > maxDepth) { + return; + } + + // Skip if not a valid object + if (!currentObj || typeof currentObj !== 'object') { + return; + } + + // Check if current object matches criteria + let matchCount = 0; + const criteriaKeys = Object.keys(criteria); + + for (const key of criteriaKeys) { + if (key in currentObj && compareValues(currentObj[key], criteria[key])) { + matchCount++; + } + } + + const isMatch = partialMatch + ? matchCount > 0 + : matchCount === criteriaKeys.length; + + // If matched, add to results + if (isMatch && criteriaKeys.length > 0) { + results.push(currentPath); + if (returnFirstMatch) { + return; + } + } + + // Recursively search nested objects and arrays + if (Array.isArray(currentObj)) { + for (let i = 0; i < currentObj.length; i++) { + const item = currentObj[i]; + if (item && typeof item === 'object') { + const nextPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`; + search(item, nextPath, depth + 1); + if (returnFirstMatch && results.length > 0) { + return; + } + } + } + } else { + for (const [key, value] of Object.entries(currentObj)) { + if (value && typeof value === 'object') { + const nextPath = currentPath ? `${currentPath}.${key}` : key; + search(value, nextPath, depth + 1); + if (returnFirstMatch && results.length > 0) { + return; + } + } + } + } + }; + + search(obj); + return results; +} + + + +/** + * Finds objects in a nested structure that match specified key-value pairs + * Returns paths compatible with setNestedValue function + * @param obj - Source object to search within + * @param criteria - Object with key-value pairs to match against + * @returns Array of paths where matching objects were found + */ +export function findObjectPath( + obj: Record, + criteria: Record +): string[] { + const results: string[] = []; + + // Handle invalid inputs + if (!obj || typeof obj !== 'object') { + return results; + } + + // Handle empty criteria - should return empty array + if (!criteria || typeof criteria !== 'object' || Object.keys(criteria).length === 0) { + return results; + } + + const search = (currentObj: any, path: string = '') => { + // Skip if not an object or is null + if (!currentObj || typeof currentObj !== 'object') { + return; + } + + // Check if current object matches all criteria + let matches = true; + for (const [key, value] of Object.entries(criteria)) { + if (!(key in currentObj) || currentObj[key] !== value) { + matches = false; + break; + } + } + + if (matches) { + results.push(path); + } + + // Continue searching in nested properties + if (Array.isArray(currentObj)) { + for (let i = 0; i < currentObj.length; i++) { + const newPath = path ? `${path}[${i}]` : `[${i}]`; + search(currentObj[i], newPath); + } + } else { + for (const [key, value] of Object.entries(currentObj)) { + const newPath = path ? `${path}.${key}` : key; + search(value, newPath); + } + } + }; + + search(obj); + return results; +} \ No newline at end of file