diff --git a/src/objects.ts b/src/objects.ts index 86f9885..6f879d8 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -1,309 +1,311 @@ -import {JSONSanitize} from './json.ts'; - -export type Delta = { [key: string]: any | Delta | null }; - -/** - * Applies deltas to `target`. - * @param base starting point - * @param deltas List of deltas to apply - * @returns Mutated target - */ -export function applyDeltas(base: any, ...deltas: any[]): any { - function applyDelta(base: any, delta: any): any { - if(delta === null) return null; - if(typeof base !== 'object' || base === null) return delta === undefined ? base : delta; - const result = Array.isArray(base) ? [...base] : { ...base }; - for(const key in delta) { - const val = delta[key]; - if(val === undefined) delete result[key]; - else if(typeof val === 'object' && val !== null && !Array.isArray(val)) result[key] = applyDelta(result[key], val); - else result[key] = val; - } - return result; - } - - for(const d of deltas.flat()) base = applyDelta(base, d?.delta ?? d); - return base; -} - -/** - * Creates a nested delta that reverts `target` back to `old`. - * @param old - Original object - * @param updated - Modified object - * @returns New changes - */ -export function calcDelta(old: any, updated: any): any { - if(updated == null) return null; // full delete - const delta: any = {}; - const isObj = (v: any) => v && typeof v === 'object' && !Array.isArray(v); - for (const key of new Set([...(old ? Object.keys(old) : []), ...(updated ? Object.keys(updated) : [])])) { - const oldVal = old?.[key]; - const newVal = updated?.[key]; - if(isObj(oldVal) && isObj(newVal)) { - const nested = calcDelta(oldVal, newVal); - if(nested !== null && Object.keys(nested).length > 0) delta[key] = nested; - } else if(JSON.stringify(oldVal) !== JSON.stringify(newVal)) { - delta[key] = newVal; - } - } - return Object.keys(delta).length === 0 ? {} : delta; -} - -/** - * Removes any null values from an object in-place - * - * @example - * ```ts - * let test = {a: 0, b: false, c: null, d: 'abc'} - * console.log(clean(test)); // Output: {a: 0, b: false, d: 'abc'} - * ``` - * - * @param {T} obj Object reference that will be cleaned - * @param undefinedOnly Ignore null values - * @returns {Partial} Cleaned object - */ -export function clean(obj: T, undefinedOnly = false): Partial { - if(obj == null) throw new Error("Cannot clean a NULL value"); - if(Array.isArray(obj)) { - obj = obj.filter(o => undefinedOnly ? o !== undefined : o != null); - } else { - Object.entries(obj).forEach(([key, value]) => { - if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (obj)[key]; - }); - } - return obj; -} - -/** - * Create a deep copy of an object (vs. a shallow copy of references) - * - * Should be replaced by `structuredClone` once released. - * @param {T} value Object to copy - * @returns {T} Type - */ -export function deepCopy(value: T): T { - if(value == null) return value; - const t = typeof value; - if(t === 'string' || t === 'number' || t === 'boolean' || t === 'function') return value; - try {return structuredClone(value); } - catch { return JSON.parse(JSONSanitize(value)); } -} - -/** - * Merge any number of objects into the target - * - * @param target Destination of all properties - * @param sources Objects that will copied into target - * @return {any} The des - */ -export function deepMerge(target: any, ...sources: any[]): T { - sources.forEach(s => { - for(const key in s) { - if(s[key] && typeof s[key] == 'object' && !Array.isArray(s[key])) { - if(!target[key]) target[key] = {}; - deepMerge(target[key], s[key]); - } else { - target[key] = s[key]; - } - } - }); - return target; -} - -/** - * Get/set a property of an object using dot notation - * - * @example - * ```ts - * // Get a value - * const name = dotNotation(person, 'firstName'); - * const familyCarMake = dotNotation(family, 'cars[0].make'); - * // Set a value - * dotNotation(family, 'cars[0].make', 'toyota'); - * ``` - * - * @type T Return type - * @param {Object} obj source object to search - * @param {string} prop property name (Dot notation & indexing allowed) - * @param {any} set Set object property to value, omit to fetch value instead - * @return {T} property value - */ -export function dotNotation(obj: any, prop: string, set: T): T; -export function dotNotation(obj: any, prop: string): T | undefined; -export function dotNotation(obj: any, prop: string, set?: T): T | undefined { - if(obj == null || !prop) return undefined; - // Split property string by '.' or [index] - return prop.split(/[.[\]]/g).filter(prop => prop.length).reduce((obj, prop, i, arr) => { - if(prop[0] == '"' || prop[0] == "'") prop = prop.slice(1, -1); // Take quotes out - if(!obj?.hasOwnProperty(prop)) { - if(set == undefined) return undefined; - obj[prop] = {}; - } - if(set !== undefined && i == arr.length - 1) - return obj[prop] = set; - return obj[prop]; - }, obj); -} - -/** - * Convert object into URL encoded query string - * - * @example - * ```js - * const query = encodeQuery({page: 1, size: 20}); - * console.log(query); // Output: "page=1&size=20" - * ``` - * - * @param {any} data - data to convert - * @returns {string} - Encoded form data - */ -export function encodeQuery(data: any): string { - return Object.entries(data).map(([key, value]) => - encodeURIComponent(key) + '=' + encodeURIComponent(value) - ).join('&'); -} - -/** - * Recursively flatten a nested object, while maintaining key structure - * - * @example - * ```ts - * const car = {honda: {model: "Civic"}}; - * console.log(flattenObj(car)); //Output {honda.model: "Civic"} - * ``` - * - * @param obj - Object to flatten - * @param parent - Recursively check if key is a parent key or not - * @param result - Result - * @returns {object} - Flattened object - */ -export function flattenObj(obj: any, parent?: any, result: any = {}) { - if(typeof obj === "object" && !Array.isArray(obj)) { - for(const key of Object.keys(obj)) { - const propName = parent ? `${parent}.${key}` : key; - if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) { - flattenObj(obj[key], propName, result); - } else { - result[propName] = obj[key]; - } - } - return result; - } -} - -/** - * Convert object to FormData - * - * @param target - Object to convert - * @return {FormData} - Form object - */ -export function formData(target: any): FormData { - const data = new FormData(); - Object.entries(target).forEach(([key, value]) => data.append(key, value)); - return data; -} - -/** - * Check that an object has the following values - * - * @example - * ```ts - * const test = {a: 2, b: 2}; - * includes(test, {a: 1}); // true - * includes(test, {b: 1, c: 3}); // false - * ``` - * - * @param target Object to search - * @param values Criteria to check against - * @param allowMissing Only check the keys that are available on the target - * @returns {boolean} Does target include all the values - */ -export function includes(target: any, values: any, allowMissing = false): boolean { - if(target == undefined) return allowMissing; - if(Array.isArray(values)) return values.findIndex((e: any, i: number) => !includes(target[i], values[i], allowMissing)) == -1; - const type = typeof values; - if(type != typeof target) return false; - if(type == 'object') { - return Object.keys(values).find(key => !includes(target[key], values[key], allowMissing)) == null; - } - if(type == 'function') return target.toString() == values.toString(); - return target == values; -} - -/** - * Deep check if two items are equal. - * Handles primitives, objects, arrays, functions, Date, RegExp, and circular references. - * - * @param {any} a - first item to compare - * @param {any} b - second item to compare - * @param {WeakMap} [seen] - Internal parameter to track circular references - * @returns {boolean} True if they match - */ -export function isEqual(a: any, b: any, seen = new WeakMap()): boolean { - // Simple cases - if(a === b) return true; - if(a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); - if(a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags; - - // Null checks - if(typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { - if(Number.isNaN(a) && Number.isNaN(b)) return true; - if(typeof a === 'function' && typeof b === 'function') return a.toString() === b.toString() - return false; - } - - // Circular references - if(seen.has(a)) return seen.get(a) === b; - seen.set(a, b); - const isArrayA = Array.isArray(a); - const isArrayB = Array.isArray(b); - - // Array checks - if(isArrayA && isArrayB) { - if(a.length !== b.length) return false; - for(let i = 0; i < a.length; i++) { - if(!isEqual(a[i], b[i], seen)) return false; - } - return true; - } - if(isArrayA !== isArrayB) return false; - - // Key & value deep comparison - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if(keysA.length !== keysB.length) return false; - for(const key of keysA) { - if(!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key], seen)) return false; - } - return true; -} - -/** - * Experimental: Combine multiple object prototypes into one - * - * @param target Object that will have prototypes added - * @param {any[]} constructors Additionally prototypes that should be merged into target - */ -export function mixin(target: any, constructors: any[]) { - constructors.forEach(c => { - Object.getOwnPropertyNames(c.prototype).forEach((name) => { - Object.defineProperty( - target.prototype, - name, - Object.getOwnPropertyDescriptor(c.prototype, name) || - Object.create(null) - ); - }); - }); -} - -/** - * Run a map function on each property - * @param obj Object that will be iterated - * @param {(key: string, value: any) => any} fn Transformer function - receives key & value - * @returns {{}} - */ -export function objectMap(obj: any, fn: (key: string, value: any) => any): T { - return Object.entries(obj).map(([key, value]: [string, any]) => [key, fn(key, value)]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); -} +import {JSONSanitize} from './json.ts'; + +export type Delta = { [key: string]: any | Delta | null }; + +/** + * Applies deltas to `target`. + * @param base starting point + * @param deltas List of deltas to apply + * @returns Mutated target + */ +export function applyDeltas(base: any, ...deltas: any[]): any { + function applyDelta(base: any, delta: any): any { + if(delta === null) return null; + if(typeof base !== 'object' || base === null) return delta === undefined ? base : delta; + const result = Array.isArray(base) ? [...base] : { ...base }; + for(const key in delta) { + const val = delta[key]; + if(val === undefined) delete result[key]; + else if(typeof val === 'object' && val !== null && !Array.isArray(val)) result[key] = applyDelta(result[key], val); + else result[key] = val; + } + return result; + } + + for(const d of deltas.flat()) base = applyDelta(base, d?.delta ?? d); + return base; +} + +/** + * Creates a nested delta that reverts `target` back to `old`. + * @param old - Original object + * @param updated - Modified object + * @returns New changes + */ +export function calcDelta(old: any, updated: any): any { + if(updated == null) return null; // full delete + const delta: any = {}; + const isObj = (v: any) => v && typeof v === 'object' && !Array.isArray(v); + for (const key of new Set([...(old ? Object.keys(old) : []), ...(updated ? Object.keys(updated) : [])])) { + const oldVal = old?.[key]; + const newVal = updated?.[key]; + if(isObj(oldVal) && isObj(newVal)) { + const nested = calcDelta(oldVal, newVal); + if(nested !== null && Object.keys(nested).length > 0) delta[key] = nested; + } else if(JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + delta[key] = newVal; + } + } + return Object.keys(delta).length === 0 ? {} : delta; +} + +/** + * Removes any null values from an object in-place + * + * @example + * ```ts + * let test = {a: 0, b: false, c: null, d: 'abc'} + * console.log(clean(test)); // Output: {a: 0, b: false, d: 'abc'} + * ``` + * + * @param {T} obj Object reference that will be cleaned + * @param undefinedOnly Ignore null values + * @returns {Partial} Cleaned object + */ +export function clean(obj: T, undefinedOnly = false): Partial { + if(obj == null) throw new Error("Cannot clean a NULL value"); + if(Array.isArray(obj)) { + obj = obj.filter(o => undefinedOnly ? o !== undefined : o != null); + } else { + Object.entries(obj).forEach(([key, value]) => { + if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (obj)[key]; + }); + } + return obj; +} + +/** + * Create a deep copy of an object (vs. a shallow copy of references) + * + * Should be replaced by `structuredClone` once released. + * @param {T} value Object to copy + * @returns {T} Type + */ +export function deepCopy(value: T): T { + if(value == null) return value; + const t = typeof value; + if(t === 'string' || t === 'number' || t === 'boolean' || t === 'function') return value; + try {return structuredClone(value); } + catch { return JSON.parse(JSONSanitize(value)); } +} + +/** + * Merge any number of objects into the target + * + * @param target Destination of all properties + * @param sources Objects that will copied into target + * @return {any} The des + */ +export function deepMerge(target: any, ...sources: any[]): T { + const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']); + sources.forEach(s => { + for(const key in s) { + if(BLOCKED.has(key) || !Object.prototype.hasOwnProperty.call(s, key)) continue; + if(s[key] && typeof s[key] == 'object' && !Array.isArray(s[key])) { + if(!target[key]) target[key] = {}; + deepMerge(target[key], s[key]); + } else { + target[key] = s[key]; + } + } + }); + return target; +} + +/** + * Get/set a property of an object using dot notation + * + * @example + * ```ts + * // Get a value + * const name = dotNotation(person, 'firstName'); + * const familyCarMake = dotNotation(family, 'cars[0].make'); + * // Set a value + * dotNotation(family, 'cars[0].make', 'toyota'); + * ``` + * + * @type T Return type + * @param {Object} obj source object to search + * @param {string} prop property name (Dot notation & indexing allowed) + * @param {any} set Set object property to value, omit to fetch value instead + * @return {T} property value + */ +export function dotNotation(obj: any, prop: string, set: T): T; +export function dotNotation(obj: any, prop: string): T | undefined; +export function dotNotation(obj: any, prop: string, set?: T): T | undefined { + if(obj == null || !prop) return undefined; + // Split property string by '.' or [index] + return prop.split(/[.[\]]/g).filter(prop => prop.length).reduce((obj, prop, i, arr) => { + if(prop[0] == '"' || prop[0] == "'") prop = prop.slice(1, -1); // Take quotes out + if(!obj?.hasOwnProperty(prop)) { + if(set == undefined) return undefined; + obj[prop] = {}; + } + if(set !== undefined && i == arr.length - 1) + return obj[prop] = set; + return obj[prop]; + }, obj); +} + +/** + * Convert object into URL encoded query string + * + * @example + * ```js + * const query = encodeQuery({page: 1, size: 20}); + * console.log(query); // Output: "page=1&size=20" + * ``` + * + * @param {any} data - data to convert + * @returns {string} - Encoded form data + */ +export function encodeQuery(data: any): string { + return Object.entries(data).map(([key, value]) => + encodeURIComponent(key) + '=' + encodeURIComponent(value) + ).join('&'); +} + +/** + * Recursively flatten a nested object, while maintaining key structure + * + * @example + * ```ts + * const car = {honda: {model: "Civic"}}; + * console.log(flattenObj(car)); //Output {honda.model: "Civic"} + * ``` + * + * @param obj - Object to flatten + * @param parent - Recursively check if key is a parent key or not + * @param result - Result + * @returns {object} - Flattened object + */ +export function flattenObj(obj: any, parent?: any, result: any = {}) { + if(typeof obj === "object" && !Array.isArray(obj)) { + for(const key of Object.keys(obj)) { + const propName = parent ? `${parent}.${key}` : key; + if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) { + flattenObj(obj[key], propName, result); + } else { + result[propName] = obj[key]; + } + } + return result; + } +} + +/** + * Convert object to FormData + * + * @param target - Object to convert + * @return {FormData} - Form object + */ +export function formData(target: any): FormData { + const data = new FormData(); + Object.entries(target).forEach(([key, value]) => data.append(key, value)); + return data; +} + +/** + * Check that an object has the following values + * + * @example + * ```ts + * const test = {a: 2, b: 2}; + * includes(test, {a: 1}); // true + * includes(test, {b: 1, c: 3}); // false + * ``` + * + * @param target Object to search + * @param values Criteria to check against + * @param allowMissing Only check the keys that are available on the target + * @returns {boolean} Does target include all the values + */ +export function includes(target: any, values: any, allowMissing = false): boolean { + if(target == undefined) return allowMissing; + if(Array.isArray(values)) return values.findIndex((e: any, i: number) => !includes(target[i], values[i], allowMissing)) == -1; + const type = typeof values; + if(type != typeof target) return false; + if(type == 'object') { + return Object.keys(values).find(key => !includes(target[key], values[key], allowMissing)) == null; + } + if(type == 'function') return target.toString() == values.toString(); + return target == values; +} + +/** + * Deep check if two items are equal. + * Handles primitives, objects, arrays, functions, Date, RegExp, and circular references. + * + * @param {any} a - first item to compare + * @param {any} b - second item to compare + * @param {WeakMap} [seen] - Internal parameter to track circular references + * @returns {boolean} True if they match + */ +export function isEqual(a: any, b: any, seen = new WeakMap()): boolean { + // Simple cases + if(a === b) return true; + if(a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + if(a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags; + + // Null checks + if(typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { + if(Number.isNaN(a) && Number.isNaN(b)) return true; + if(typeof a === 'function' && typeof b === 'function') return a.toString() === b.toString() + return false; + } + + // Circular references + if(seen.has(a)) return seen.get(a) === b; + seen.set(a, b); + const isArrayA = Array.isArray(a); + const isArrayB = Array.isArray(b); + + // Array checks + if(isArrayA && isArrayB) { + if(a.length !== b.length) return false; + for(let i = 0; i < a.length; i++) { + if(!isEqual(a[i], b[i], seen)) return false; + } + return true; + } + if(isArrayA !== isArrayB) return false; + + // Key & value deep comparison + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if(keysA.length !== keysB.length) return false; + for(const key of keysA) { + if(!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key], seen)) return false; + } + return true; +} + +/** + * Experimental: Combine multiple object prototypes into one + * + * @param target Object that will have prototypes added + * @param {any[]} constructors Additionally prototypes that should be merged into target + */ +export function mixin(target: any, constructors: any[]) { + constructors.forEach(c => { + Object.getOwnPropertyNames(c.prototype).forEach((name) => { + Object.defineProperty( + target.prototype, + name, + Object.getOwnPropertyDescriptor(c.prototype, name) || + Object.create(null) + ); + }); + }); +} + +/** + * Run a map function on each property + * @param obj Object that will be iterated + * @param {(key: string, value: any) => any} fn Transformer function - receives key & value + * @returns {{}} + */ +export function objectMap(obj: any, fn: (key: string, value: any) => any): T { + return Object.entries(obj).map(([key, value]: [string, any]) => [key, fn(key, value)]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); +}