lib/obj.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

const util = require('util');
const lib = require(__dirname + '/../lib');


/**
 * Return the length of an array or 0 if it is not an array
 * @param {any[]} list
 * @return {int}
 * @memberof module:lib
 * @method arrayLength
 */
lib.arrayLength = function(list)
{
    return Array.isArray(list) && list.length || 0;
}

/**
 * Remove the given item from the list in place, returns the same list
 * @param {any[]} list
 * @param {any} item
 * @return {any[]}
 * @memberof module:lib
 * @method arrayRemove
 */
lib.arrayRemove = function(list, item)
{
    var idx = this.isArray(list, this.emptylist).indexOf(item);
    if (idx > -1) list.splice(idx, 1);
    return list;
}

/**
 * Returns only unique items in the array, optional `key` specified the name of the column to use when determining uniqueness if items are objects.
 * @param {any[]} list
 * @param {string} key
 * @memberof module:lib
 * @method arrayUnique
 */
lib.arrayUnique = function(list, key)
{
    if (!Array.isArray(list)) {
        return this.split(list, null, { unique: 1 });
    }
    var rc = [], keys = {};
    list.forEach((x) => {
        if (key) {
            if (!keys[x[key]]) rc.push(x);
            keys[x[key]] = 1;
        } else {
            if (rc.indexOf(x) == -1) rc.push(x);
        }
    });
    return rc;
}

/**
 * Returns true if both arrays contain same items, only primitive types are supported
 * @param {any[]} list1
 * @param {any[]} list2
 * @memberof module:lib
 * @method @memberof module:lib
 * @method arrayEqual
 */
lib.arrayEqual = function(list1, list2)
{
    if (!Array.isArray(list1) || !Array.isArray(list2) || list1.length != list2.length) return false;
    for (let i = 0; i < list1.length; ++i) {
        if (!list2.includes(list1[i])) return false;
    }
    return true;
}

/**
 * Flatten array of arrays into a single array
 * @param {any[]} list
 * @memberof module:lib
 * @method arrayFlatten
 */
lib.arrayFlatten = function(list)
{
    list = Array.prototype.concat.apply([], list);
    return list.some(Array.isArray) ? this.arrayFlatten(list) : list;
}

/**
 * A shallow copy of an object, still it is deeper than Object.assign for arrays, objects, sets and maps,
 * these created new but all other types are just references as in Object.assign
 * @param {object} obj - first argument is the object to clone, can be null
 * @param {...any} [args] - additional arguments are treated as object to be merged into the result using Object.assign
 * @return {object}
 * @example
 *    const a = { 1: 2, a: [1, 2] }
 *    const b = lib.clone(a, { "3": 3, "4": 4 })
 *    b.a.push(3)
 *    a.a.length != b.length
 * @memberof module:lib
 * @method clone
 */
lib.clone = function(obj, ...args)
{
    var rc = Array.isArray(obj) ? [] : {}, o1, o2;
    for (const p in obj) {
        if (!obj.hasOwnProperty(p)) continue;
        o1 = obj[p];
        switch (this.typeName(o1)) {
        case "object":
            rc[p] = Object.assign({}, o1);
            break;
        case "map":
            rc[p] = o2 = new Map();
            for (const k of o1) o2.set(k[0], k[1]);
            break;
        case "set":
            rc[p] = o2 = new Set();
            for (const k of o1) o2.add(k);
            break;
        case "array":
            rc[p] = o1.slice(0);
            break;
        default:
            rc[p] = o1;
        }
    }
    for (const arg of args) {
        Object.assign(rc, arg);
    }
    return rc;
}

/**
 * Flatten a javascript object into a single-depth object, all nested values will have property names appended separated by comma
 * @param {object} obj
 * @param {object} [options]
 *  - separator - use something else instead of .
 *  - index - initial index for arrays, 0 is default
 *  - ignore - regexp with properties to ignore
 *  - depth - only go this deep into objects
 * @return {object}
 * @example
 * > lib.flatten({ a: { c: 1 }, b: { d: 1 } } )
 * { 'a.c': 1, 'b.d': 1 }
 * > lib.flatten({ a: { c: 1 }, b: { d: [1,2,3] } }, { index: 1 })
 * { 'a.c': 1, 'b.d.1': 1, 'b.d.2': 2, 'b.d.3': 3 }
 * @memberof module:lib
 * @method flatten
 */
lib.flatten = function(obj, options)
{
    const rc = {};
    const idx = Array.isArray(obj) && typeof options?.index == "number" ? options.index : 0;
    const sep = options?.separator || '.';

    if (!options) options = { __depth: 0 };

    for (let p in obj) {
        if (typeof options.ignore?.test == "function" && options.ignore.test(p)) continue;

        var v = obj[p];
        p = idx ? lib.toNumber(p) + idx : p;

        if (typeof v !== 'object' || v === null) {
            if (v !== undefined) rc[p] = v;
            continue;
        }

        if (options.depth && options.__depth >= options.depth) {
            continue;
        }
        if (typeof options.__depth != "number") {
            options = Object.assign({}, options, { __depth: 0 });
        }
        if (!options.__seen) {
            options.__seen = new WeakSet();
        }
        if (options.__seen.has(v)) {
            continue;
        }
        options.__seen.add(v);
        options.__depth++;

        const v2 = this.flatten(v, options);
        const idx2 = Array.isArray(v) && typeof options?.index == "number" ? options.index : 0;
        for (const x in v2) {
            const p2 = idx2 ? lib.toNumber(x) + idx2 : x;
            rc[p + sep + p2] = v2[x];
        }

        options.__depth--;
    }
    return rc;
}

/**
 * Extend the object with properties similar to Object.assign but perform deep merge
 * @param {object} obj
 * @param {object[]} ...args
 * @return {object}
 * @example
 * lib.extend({ a:1, c:5 }, { c: { b: 2 }, d: [{ d: 3 }] }, { c: { a: 2 }})
 * { a: 1, c: { b: 2, a: 2 }, d: [ { d: 3 } ] }
 * @memberof module:lib
 * @method extend
 */
lib.extend = function(obj, ...args)
{
    obj = typeof obj == "object" || typeof obj == "function" ? obj || {} : {};
    for (const val of args) {
        for (const p in val) {
            const v = val[p];
            if (v === obj) continue;
            if (p === "__proto__") continue;
            if (v) {
                if (Array.isArray(v)) {
                    obj[p] = this.extend(Array.isArray(obj[p]) ? obj[p] : [], v);
                    continue;
                } else
                if (this.typeName(v) === "object") {
                    obj[p] = this.extend(obj[p], v);
                    continue;
                }
            }
            obj[p] = v;
        }
    }
    return obj;
}

/**
 * Return a property from the object, name specifies the path to the property, if the required property belong to another object inside the top one
 * the name uses . to separate objects. This is a convenient method to extract properties from nested objects easily.
 * @param {object} obj
 * @param {string} name
 * @param {object} [options]
 * Options may contains the following properties:
 *   - list - return the value as a list even if there is only one value found
 *   - obj - return the value as an object, if the result is a simple type, wrap into an object like { name: name, value: result }
 *   - str - return the value as a string, convert any other type into string
 *   - num - return the value as a number, convert any other type by using toNumber
 *   - func - return the value as a function, if the object is not a function returns null
 *   - owner - return the owner object, not the value, i.e. return the object who owns the value specified in the name
 * @Return {any}
 * @example
 * > lib.objGet({ response: { item : { id: 123, name: "Test" } } }, "response.item.name")
 * "Test"
 * > lib.objGet({ response: { item : { id: 123, name: "Test" } } }, "response.item.name", { list: 1 })
 * [ "Test" ]
 * > lib.objGet({ response: { item : { id: 123, name: "Test" } } }, "response.item.name", { owner: 1 })
 * { item : { id: 123, name: "Test" } }
 * @memberof module:lib
 * @method objGet
 */
lib.objGet = function(obj, name, options)
{
    if (!obj) {
        if (!options) return null;
        return options.list ? [] : options.obj ? {} : options.str ? "" : options.num ? options.dflt || 0 : null;
    }
    var path = !Array.isArray(name) ? String(name).split(".") : name, owner = obj;
    for (var i = 0; i < path.length; i++) {
        if (i && owner) owner = owner[path[i - 1]];
        obj = obj ? obj[path[i]] : undefined;
        if (typeof obj == "undefined") {
            if (!options) return obj;
            return options.owner && i == path.length - 1 ? owner : options.list ? [] : options.obj ? {} : options.str ? "" : options.num ? options.dflt || 0 : undefined;
        }
    }
    if (options) {
        if (options.owner) return owner;
        if (obj) {
            if (options.func && typeof obj != "function") return null;
            if (options.list && !Array.isArray(obj)) return [ obj ];
            if (options.obj && typeof obj != "object") return { name: name, value: obj };
            if (options.str && typeof obj != "string") return String(obj);
            if (options.num && typeof obj != "number") return this.toNumber(obj, options);
        }
    }
    return obj;
}

/**
 * Set a property of the object, name can be an array or a string with property path inside the object, all non existent intermediate
 * objects will be create automatically. The options can have the folowing properties:
 * @param {object} obj
 * @param {string} name
 * @param {any} value
 * @param {object} [options]
 * - incr - increment a numeric property with the given number or 1, 0 is noop, non-existing propertties will be initilaized with 0
 * - mult - multiply a numeric property with the given number, non-existing properties will be initialized with 0
 * - push - add to the array, if it is not an array a new empty aray is created
 * - append - append to a string
 * - unique - only push if not in the list
 * - separator - separator for object names, default is `.`
 * - result - "new" - new value, "old" - old value, "obj" - final object, otherwise the original object itself
 * @return {any}
 * @example
 * var a = lib.objSet({}, "response.item.count", 1)
 * lib.objSet(a, "response.item.count", 1, { incr: 1 })
 * @memberof module:lib
 * @method objSet
 */
lib.objSet = function(obj, name, value, options)
{
    if (this.typeName(obj) != "object") obj = {};
    if (!Array.isArray(name)) name = String(name).split(options?.separator || ".");
    if (!name?.length) return obj;
    var p = name[name.length - 1], v = obj;
    for (var i = 0; i < name.length - 1; i++) {
        if (typeof obj[name[i]] == "undefined") obj[name[i]] = {};
        obj = obj[name[i]];
    }
    var old = obj[p];
    if (options?.push) {
        if (!Array.isArray(obj[p])) obj[p] = old = [];
        if (!options.unique || obj[p].indexOf(value) == -1) obj[p].push(value);
    } else
    if (options?.append) {
        if (typeof obj[p] != "string") obj[p] = old = "";
        obj[p] += value;
    } else
    if (options?.mult) {
        if (typeof obj[p] != "number") obj[p] = old = 0;
        obj[p] *= typeof value == "number" ? value : lib.toNumber(value) || 1;
    } else
    if (options?.incr) {
        if (value !== 0) {
            if (typeof obj[p] != "number") obj[p] = old = 0;
            if (obj[p] >= Number.MAX_SAFE_INTEGER) obj[p] = 0;
            obj[p] += lib.toNumber(value) || 1;
        }
    } else {
        obj[p] = value;
    }
    switch (options?.result) {
    case "old": return old;
    case "obj": return obj;
    case "new": return obj[p];
    }
    return v;
}

/**
 * Increment a property by the specified number, if the property does not exist it will be created,
 * returns new incremented value or the value specified by the `result` argument.
 * It uses `lib.objSet` so the property name can be a nested path.
 * @param {object} obj
 * @param {string} name
 * @param {number} count
 * @param {string} [result=new]
 * @memberof module:lib
 * @method objIncr
 */
lib.objIncr = function(obj, name, count, result)
{
    return this.objSet(obj, name, count, { incr: 1, result: result || "new" });
}

/**
 * Similar to `objIncr` but does multiplication
 * @param {object} obj
 * @param {string} name
 * @param {number} count
 * @param {strings} [result=new]
 * @memberof module:lib
 * @method objMult
 */
lib.objMult = function(obj, name, count, result)
{
    return this.objSet(obj, name, count, { mult: 1, result: result || "new" });
}

/**
 * Return all property names for an object
 * @param {object} obj
 * @return {string[]}
 * @memberof module:lib
 * @method objKeys
 */
lib.objKeys = function(obj)
{
    return this.isObject(obj) ? Object.keys(obj) : [];
}

/**
 * Calculate the size of the whole object, this is not exact JSON size, for speed it
 * summarizes approximate size of each property recursively
 * @param {object} [options]
 * @param {int} [options.depth] - limits how deep it goes, on limit returns MAX_SAFE_INTEGER+ number
 * @param {boolean} [options.nan] - if true return NaN on reaching the limits
 * @param {int} [options.pad] - extra padding added for each property, default is 5 to simulate JSON encoding, "..": ".."
 * @return {int} the size of the whole object
 * @memberof module:lib
 * @method objSize
 */
lib.objSize = function(obj, options, priv)
{
    if (typeof obj != "object") {
        return typeof obj == "string" ? obj.length : String(obj).length;
    }
    if (util.types.isProxy(obj)) return 0;

    var rc = 0, depth = options?.depth || 10, pad = lib.toNumber(options?.pad) || 5;
    if (this.typeName(obj) != "object") obj = { "": obj };
    if (typeof priv?.depth != "number") priv = { depth: 0, seen: new WeakSet() };

    for (const p in obj) {
        let v = obj[p];
        rc += p.length + pad;

        switch (this.typeName(v)) {
        case "array":
            if (priv.depth > depth || priv.seen.has(v)) return options?.nan ? NaN : Number.MAX_SAFE_INTEGER;
            priv.seen.add(v);
            priv.depth++;
            for (const k of v) rc += this.objSize(k, options, priv);
            priv.depth--;
            break;

        case "set":
            if (priv.depth > depth || priv.seen.has(v)) return options?.nan ? NaN : Number.MAX_SAFE_INTEGER;
            priv.seen.add(v);
            v = Array.from(v);
            priv.depth++;
            for (const k of v) rc += this.objSize(k, options, priv);
            priv.depth--;
            break;

        case "map":
            if (priv.depth > depth || priv.seen.has(v)) return options?.nan ? NaN : Number.MAX_SAFE_INTEGER;
            priv.seen.add(v);
            priv.depth++;
            for (const k of v) rc += this.objSize(k[0]) + this.objSize(k[1]);
            priv.depth--;
            break;

        case "error":
            if (priv.depth > depth || priv.seen.has(v)) return options?.nan ? NaN : Number.MAX_SAFE_INTEGER;
            priv.seen.add(v);
            rc += v.message?.length;
            for (const k in v) rc += k.length + this.objSize(k[v], options, priv);
            break;

        case "object":
            if (priv.depth > depth || priv.seen.has(v)) return options?.nan ? NaN : Number.MAX_SAFE_INTEGER;
            priv.seen.add(v);
            priv.depth++;
            rc += this.objSize(v, options, priv);
            priv.depth--;
            break;

        case "string":
        case "buffer":
            rc += v.length;
            break;

        case "proxy":
        case "function":
            break;

        default:
            v = "" + v;
            rc += v.length;
        }
    }
    return rc;
}