Added findObjectPath, findObjectByKeyValues

This commit is contained in:
Hein 2025-05-02 14:16:43 +02:00
parent 57129e46d5
commit de50af7446
2 changed files with 349 additions and 1 deletions

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { getNestedValue, setNestedValue } from "./nested"; import { findObjectByKeyValues, findObjectPath, getNestedValue, setNestedValue } from "./nested";
describe("getNestedValue", () => { describe("getNestedValue", () => {
const testObj = { const testObj = {
@ -170,3 +170,178 @@ describe("setNestedValue", () => {
expect(obj.list[0]).toBe("item"); 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();
});
});

View File

@ -102,3 +102,176 @@ export function setNestedValue(
current[lastKey] = value; current[lastKey] = value;
return obj; return obj;
} }
export function findObjectByKeyValues(
obj: Record<string, any>,
criteria: Record<string, any>,
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<string, any>,
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<string, any>,
criteria: Record<string, any>
): 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;
}