UNPKG

dbl-utils

Version:

Utilities for dbl, adba and others projects

377 lines (333 loc) 10.8 kB
import { deepMerge } from "./object-mutation"; import { v4 as uuidv4 } from "uuid"; // Formatter functions by type const FORMATTERS: Record<string, (v: string) => any> = { string: (v) => v, number: (v) => Number(v), boolean: (v) => v.toLowerCase() === "true", }; /** * Type definition for the options parameter */ type FlatCopyOptions = { delimiter?: string; ommit?: string[]; ommitArrays?: boolean; prefix?: string; ommitFn?: (key: string, value: any, newIndex: string) => boolean; }; /** * Flattens an object into a single-level object with delimited keys. * * * @param object - The object to flatten * @param options - Configuration options for flattening * @returns A single-level object with flattened keys * @throws Will throw an error if the input is not an object * * @example * ```ts * const result = flatten({ a: { b: 1 } }); * // result => { 'a.b': 1 } * ``` */ export function flatten( object: object, options: FlatCopyOptions = {} ): Record<string, any> { if (options.delimiter === undefined) options.delimiter = "."; if (options.ommit === undefined) options.ommit = []; if (options.ommitArrays === undefined) options.ommitArrays = false; const newObject: Record<string, any> = {}; if (typeof object !== "object" || object === null) throw new Error("Must be object"); /** * Recursively flattens the object. * * @param element - Current element being processed * @param key - Current key in the object * @param index - Current index being constructed */ const recursive = ( element: any, key: string | undefined, index?: string ): void => { const newIndex = index ? [index, key].join(options.delimiter) : key; if (typeof element === "object" && element !== null) { const ommitedByFn = typeof options.ommitFn === "function" && !!options.ommitFn(key!, element, newIndex!); if (Array.isArray(element)) { if (options.ommitArrays || ommitedByFn) { newObject[newIndex!] = element; } else { element.forEach((e, i) => recursive(e, i.toString(), newIndex)); } } else { if ((key && options.ommit?.includes(key)) || ommitedByFn) { newObject[newIndex!] = element; } else { Object.entries(element).forEach(([i, e]) => recursive(e, i, newIndex) ); } } } else { newObject[newIndex!] = element; } }; recursive(object, options.prefix); return newObject; } /** * Reconstructs a nested object from a flattened one using a delimiter. * Numeric keys are automatically treated as array indices so arrays * are recreated correctly. * * @param object - The flattened object. * @param delimiter - The string delimiter used to split keys. * @returns The nested (unflattened) object. * * @example * ```ts * const obj = { 'a.0': 1, 'a.1': 2 }; * unflatten(obj); * // => { a: [1, 2] } * ``` */ export function unflatten( object: Record<string, any>, delimiter: string = "." ): Record<string, any> { if (typeof object !== "object" || object === null) throw new Error("Input must be an object"); const result: Record<string, any> = {}; const isInteger = (key: string): boolean => /^\d+$/.test(key); for (const flatKey of Object.keys(object)) { const value = object[flatKey]; const keys = flatKey.split(delimiter); let current: any = result; for (let i = 0; i < keys.length; i++) { const key = keys[i]; const nextKey = keys[i + 1]; const isLast = i === keys.length - 1; const keyIsIndex = isInteger(key); const nextIsIndex = isInteger(nextKey); // Convert key if it should be array index const realKey = keyIsIndex ? parseInt(key, 10) : key; // Handle last key (set value) if (isLast) { current[realKey] = value; } else { // Create container if needed if (current[realKey] === undefined) { current[realKey] = nextIsIndex ? [] : {}; } // Move deeper current = current[realKey]; } } } return result; } /** * Converts a string into its original type (string, number, boolean). */ export function formatAs( value: string, type: string ): string | number | boolean { const format = FORMATTERS[type] ?? FORMATTERS.string; return format(value); } /** * Adds extra formatter functions to the internal registry. */ export const addFormatters = ( extraFormatters: Record<string, (v: string) => any> ) => { Object.assign(FORMATTERS, extraFormatters); return true; }; /** * Serializes an object or array of objects into a flat list of field entries. * You can customize the process using `before` (pre-processing per item) * and `after` (post-processing per field). * * @example * ```ts * const data = { a: 1, b: { c: 2 } }; * const entries = await serialize(data); * // entries[0] => { path: 'a', value: '1', type: 'number', uuid: '...' } * ``` */ export const serialize = async ( data: any, opts: { before?: ( element: any, index: number ) => Promise<Record<string, any> | null | false | undefined>; after?: ( path: string, value: string | number | boolean, metaInfo: Record<string, any> ) => Promise<string | number | boolean | null | undefined>; metaIdentifier?: string; groupKey?: string; } = {} ) => { // Default options opts = Object.assign( { metaIdentifier: "$", groupKey: "uuid", }, opts ); const serialized = ( await Promise.all( [data] .flat() .map(async (rawLineContent: Record<string, any>, i: number) => { const lineContent = opts.before ? deepMerge( rawLineContent, (await opts.before(rawLineContent, i)) ?? {} ) : rawLineContent; // Separate metadata fields const metaValues: Record<string, any> = {}; Object.keys(lineContent).forEach((k) => { if (!k.startsWith(opts.metaIdentifier!)) return; metaValues[k.slice(opts.metaIdentifier!.length)] = lineContent[k]; delete lineContent[k]; }); // Generate a groupKey if none is provided in metadata const groupKeyName = opts.groupKey!; metaValues[groupKeyName] = metaValues[groupKeyName] || uuidv4(); const tmp = flatten(lineContent, { delimiter: "/" }); return await Promise.all( Object.entries(tmp).map(async ([path, rawValue]) => { const cpMetaValues = { ...metaValues }; const value: string | number | boolean | null | undefined = opts.after ? await opts.after(path, rawValue, cpMetaValues) : rawValue; const obj = ([null, undefined] as any).includes(value) ? false : { path, value: String(value), type: typeof value, [groupKeyName]: metaValues[groupKeyName], // always include groupKey ...cpMetaValues, }; return obj; }) ); }) ) ) .flat() .filter(Boolean); return serialized; }; /** * Deserializes a flat list of entries into full objects. * Groups entries by a defined key (e.g. uuid or content_uuid) if present. * Optionally applies a `before` hook to each entry before processing. * * @example * ```ts * const objects = await deserialize(entries); * // objects => [{ a: 1, b: { c: 2 } }] * ``` */ export const deserialize = async ( serialized: Array<{ path: string; value: string; type: string; [meta: string]: any; }>, opts: { before?: (entry: { path: string; value: string; type: string; [meta: string]: any; }) => Promise<Record<string, any> | null | false | undefined>; metaIdentifier?: string; groupKey?: string; metaObject?: boolean; } = {} ) => { opts = Object.assign( { metaIdentifier: "$", groupKey: "uuid", metaObject: false, }, opts ); const grouped: Record<string, any> = {}; for (const rawEntry of serialized) { const entry = opts.before ? deepMerge(rawEntry, (await opts.before(rawEntry)) ?? {}) : rawEntry; const { path, value, type, ...meta } = entry; const formattedValue = formatAs(value, type); const groupId = meta[opts.groupKey!] ?? "__ungrouped__"; grouped[groupId] = grouped[groupId] ?? {}; const groupMeta = opts.metaObject ? (grouped[groupId][opts.metaIdentifier!] ??= {}) : grouped[groupId]; // Always store the groupKey const idKey = opts.metaObject ? opts.groupKey! : opts.metaIdentifier + opts.groupKey!; groupMeta[idKey] = groupMeta[idKey] ?? groupId; // Use Set to accumulate metadata Object.entries(meta).forEach(([key, rawVal]) => { let val; switch (true) { case rawVal === null: val = undefined; break; case typeof rawVal === "object" && rawVal instanceof Date: val = rawVal.toISOString(); break; case typeof rawVal === "object": val = String(rawVal); break; default: val = rawVal; break; } const metaKey = opts.metaObject ? key : opts.metaIdentifier + key; const current = groupMeta[metaKey]; if (!current) groupMeta[metaKey] = new Set([val]); else if (current instanceof Set) current.add(val); else groupMeta[metaKey] = new Set([current, val]); }); grouped[groupId][path] = formattedValue; } // Final cleanup: convert Set → single value, array or remove for (const group of Object.values(grouped)) { const container = opts.metaObject ? group[opts.metaIdentifier!] : group; for (const [key, value] of Object.entries(container)) { if (value instanceof Set) { const arr = Array.from(value).filter(Boolean); if (arr.length === 0) delete container[key]; else if (arr.length === 1) container[key] = arr[0]; else container[key] = arr; } } } const deserialized = Object.values(grouped).map((flat) => unflatten(flat, "/") ); return deserialized; };