Skip to content

Instantly share code, notes, and snippets.

@Eunomiac
Last active January 2, 2021 09:25
Show Gist options
  • Save Eunomiac/5e63f9fbb53e712b6e7c6d91365865f7 to your computer and use it in GitHub Desktop.
Save Eunomiac/5e63f9fbb53e712b6e7c6d91365865f7 to your computer and use it in GitHub Desktop.
Adding support for array indices in Foundry dot notation, both Javascript and Handlebars syntax (i.e. "array[0]" and "array.[0]")
// ***************************************
// *** CHANGED FUNCTIONS *****************
// *** (test cases at bottom) ************
// ***************************************
/**
* Flatten a possibly multi-dimensional object to a one-dimensional one by converting all nested keys to dot notation.
* Array indices will be converted to javascript-standard syntax, e.g. "data.object.array[1].object.key"
* @param {Object|Array} obj The object or array to flatten
* @param {Number} _d Recursion depth, to prevent overflow
* @param {Boolean} isParsingArrays Whether to parse bracketed integers (".[0]") to array indices. Default = true.
*
* @return {Object} A flattened object
*/
function flattenObject(obj, _d=0, isParsingArrays = true) {
const objType = getType(obj);
const bracketFirstInt = (key) => key.replace(/^\[?(\d+)\]?/u, "[$1]");
const formatDotKey = (key) => key.replace(/\.(\[\d+\])/gu, "$1");
const flat = isParsingArrays && objType === "Array" ? [] : {};
if ( _d > 10 ) {
throw new Error("Maximum depth exceeded");
}
for ( const [k, v] of Object.entries(obj) ) {
const t = getType(v);
// Inner objects & arrays
if (["Object", isParsingArrays ? "Array" : "Object"].includes(t)) {
if ( isObjectEmpty(v) ) {
flat[t === "Array" ? bracketFirstInt(k) : k] = v;
}
const inner = flattenObject(v, _d+1, isParsingArrays);
for ( const [ik, iv] of Object.entries(inner) ) {
if (isParsingArrays && getType(inner) === "Array") {
flat[formatDotKey(`${k}.${bracketFirstInt(ik)}`)] = iv;
} else {
flat[`${k}.${ik}`] = iv;
}
}
}
// Inner values
else {
flat[isParsingArrays ? formatDotKey(k) : k] = v;
}
}
return flat;
}
/**
* A simple function to test whether or not an Object or Array is empty
* @param {Object|Array} obj The object or array to test
* @return {Boolean} Is the object or array empty?
*/
function isObjectEmpty(obj) {
if ( !["Object", "Array"].includes(getType(obj)) ) {throw new Error("The provided data is not an object!")}
return Object.keys(obj).length === 0;
}
/**
* Update a source object/array by replacing its keys and values with those from a target object/array, with
* support for arrays and dot notation with bracketed array indices.
*
* @param {Object|Array} original The initial object which should be updated with values from the target
* @param {Object|Array} other A new object whose values should replace those in the source
*
* @param {boolean} [insertKeys] Control whether to insert new top-level objects into the resulting structure
* which do not previously exist in the original object.
* @param {boolean} [insertValues] Control whether to insert new nested values into child objects in the resulting
* structure which did not previously exist in the original object.
* @param {boolean} [overwrite] Control whether to replace existing values in the source, or only merge values
* which do not already exist in the original object.
* @param {boolean} [recursive] Control whether to merge inner-objects recursively (if true), or whether to
* simply replace inner objects with a provided new value.
* @param {boolean} [inplace] Control whether to apply updates to the original object in-place (if true),
* otherwise the original object is duplicated and the copy is merged.
* @param {boolean} [enforceTypes] Control whether strict type checking requires that the value of a key in the
* other object must match the data type in the original data to be merged.
* @param {boolean} [isParsingArrays] Whether to parse bracketed integers (".[0]") to array indices. Default = true.
* @param {number} [_d] A privately used parameter to track recursion depth.
*
* @returns {Object} The original source object including updated, inserted, or overwritten records.
*
* @example <caption>Control how new keys and values are added</caption>
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"}
* mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true}); // {k1: "v1", k2: "v2"}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}}
*
* @example <caption>Control how existing data is overwritten</caption>
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"}
* mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"}
*
* @example <caption>Control whether merges are performed recursively</caption>
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i1: "v2"}}
* mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}}
*
* @example <caption>Deleting an existing object key</caption>
* mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}); // {k2: "v2"}
*/
function mergeObject(original, other, {
insertKeys=true,
insertValues=true,
overwrite=true,
recursive=true,
inplace=true,
enforceTypes=false,
isParsingArrays=true
}={}, _d=0) {
const orgType = getType(original);
other = other || (isParsingArrays && orgType === "Array" ? [] : {});
const othType = getType(other);
if (orgType !== othType
|| !["Object", isParsingArrays ? "Array" : "Object"].includes(orgType)) {
throw new Error(`One of original or other are not Objects${isParsingArrays ? " or Arrays, or their types do not match" : ""}!`);
}
const depth = _d + 1;
// Maybe copy the original data at depth 0
if ( !inplace && (_d === 0) ) {original = duplicate(original)}
// Enforce object expansion at depth 0
if ( (_d === 0 ) && Object.keys(original).some(k => /\./.test(k)) ) {original = expandObject(original)}
if ( (_d === 0 ) && Object.keys(other).some(k => /\./.test(k)) ) {other = expandObject(other)}
// Iterate over the other object
for ( let [k, v] of Object.entries(other) ) {
const tv = getType(v);
// Prepare to delete
let toDelete = false;
if ( k.startsWith("-=") ) {
k = k.slice(2);
toDelete = v === null;
}
// Get the existing object
let x = original[k],
has = Object.prototype.hasOwnProperty.call(original, k),
tx = getType(x);
// Ensure that inner objects exist
if ( !has && ["Object", isParsingArrays ? "Array" : "Object"].includes(tv) ) {
x = original[k] = tv === "Array" ? [] : {};
has = true;
tx = tv;
}
// Case 1 - Key exists
if (has) {
// 1.1 - Recursively merge an inner object
if (recursive && tv === tx && ["Object", "Array"].includes(tv) ) {
mergeObject(x, v, {
insertKeys,
insertValues,
overwrite,
inplace: true,
enforceTypes,
isParsingArrays
}, depth);
}
// 1.2 - Remove an existing key
else if ( toDelete ) {
delete original[k];
}
// 1.3 - Overwrite existing value
else if ( overwrite ) {
if ( tx && (tv !== tx) && enforceTypes ) {
throw new Error("Mismatched data types encountered during object merge.");
}
original[k] = v;
}
// 1.4 - Insert new value
else if ( (x === undefined) && insertValues ) {
original[k] = v;
}
}
// Case 2 - Key does not exist
else if ( !toDelete ) {
const canInsert = (depth === 1 && insertKeys ) || ( depth > 1 && insertValues );
if (canInsert) {
original[k] = v;
}
}
}
// Return the object for use
return original;
}
/**
* A helper function which searches through an object to retrieve a value by a string key.
* The string key supports the notation a.b.c which would return object[a][b][c]. Array
* indices are referenced via bracketed integers of form ".[0]" or "[0]".
* @param {Object|Array} object The object/array to traverse
* @param {String} key An object/array property with notation a.b.c
* @param {Boolean} isParsingArrays Whether to parse bracketed integers (".[0]") to
* array indices. Default = true.
*
* @return {*} The value of the found property
*/
function getProperty(object, key, isParsingArrays = true) {
if ( !key ) {
return undefined;
}
// Replace bracketed integers with integer keys for accessing via arr[key].
key = isParsingArrays
? key.
replace(/\[(\d+)\]/gu, ".$1.").
replace(/(^\.)|\.$|\.(?=\.)/gu, "")
: key;
let target = object;
for ( const p of key.split(".") ) {
target = target || {};
if ( p in target ) {target = target[p]} else {return undefined}
}
return target;
}
/**
* A helper function which searches through an object to assign a value using a string key
* This string key supports the notation a.b.c which would target object[a][b][c]
*
* @param {Object|Array} object The object/array to update
* @param {String} key The string key
* @param {*} value The value to be assigned
* @param {Boolean} isParsingArrays Whether to parse bracketed integers (".[0]") to
* array indices. Default = true.
*
* @return {Boolean} A flag for whether or not the object was updated
*/
function setProperty(object, key, value, isParsingArrays = true) {
let target = object,
changed = false;
// Convert the key to an object reference if it contains dot notation
if ( key.indexOf(".") !== -1 ) {
const parts = (isParsingArrays
? key.
replace(/(\[\d+\])/gu, ".$1.").
replace(/(^\.)|\.$|\.(?=\.)/gu, "")
: key).
split(".").
map((k) => (isParsingArrays && /^\[\d+\]$/.test(k)
? parseInt(k.slice(1, -1))
: k)); // Coerce bracketed integers to integers
key = parts.pop();
target = parts.reduce((o, k, i, _parts) => {
if ( !Object.prototype.hasOwnProperty.call(o, k) ) {
// Declare array if next key is an integer.
// (Only possible if isParsingArrays)
o[k] = Number.isInteger([..._parts, key][i+1]) ? [] : {};
}
return o[k];
}, object);
}
// Update the target
if ( target[key] !== value ) {
changed = true;
target[key] = value;
}
// Return changed status
return changed;
}
// ***************************************
// *** TEST CASES & RESULTS **************
// ***************************************
// TEST OBJECTS
const testObjOne = {
"data.object.one": 1,
"data.object.two": {3: "tee", "four": 4},
"data.object.two.3": "three",
"data.array_1[0]": "zero",
"data.array_1[1]": "one",
"data.array_1.[2]": "[2]",
"data.array_1.[3].one": 1,
"data.array_1[3].two": 2,
"data.array_2.[2].[0]": "double-zero"
};
const testObjTwo = {
"data.array_1[0]": "zeroMerged",
"data.array_1[10]": "tenMerged",
"data.array_2[2][0]": "doubleZeroMerged"
};
// EXPANDOBJECT OUTPUT
console.dir(
[
expandObject(testObjOne),
expandObject(testObjTwo),
mergeObject(testObjOne, testObjTwo),
flattenObject(mergeObject(testObjOne, testObjTwo))
]
, {depth: null});
/*
[
{
data: {
object: { one: 1, two: { '3': 'three', four: 4 } },
array_1: [ 'zero', 'one', '[2]', { one: 1, two: 2 } ],
array_2: [ <2 empty items>, [ 'double-zero' ] ]
}
},
{
data: {
array_1: [ 'zeroMerged', <9 empty items>, 'tenMerged' ],
array_2: [ <2 empty items>, [ 'doubleZeroMerged' ] ]
}
},
{
data: {
object: { one: 1, two: { '3': 'three', four: 4 } },
array_1: [
'zeroMerged',
'one',
'[2]',
{ one: 1, two: 2 },
<6 empty items>,
'tenMerged'
],
array_2: [ <2 empty items>, [ 'doubleZeroMerged' ] ]
}
},
{
'data.object.one': 1,
'data.object.two.3': 'three',
'data.object.two.four': 4,
'data.array_1[0]': 'zeroMerged',
'data.array_1[1]': 'one',
'data.array_1[2]': '[2]',
'data.array_1[10]': 'tenMerged',
'data.array_1[3].one': 1,
'data.array_1[3].two': 2,
'data.array_2[2][0]': 'doubleZeroMerged'
}
]
*/
// TEST RESULTS
console.log(
[
[flattenObject(testObjOne), flattenObject(expandObject(testObjOne))],
[flattenObject(testObjTwo), flattenObject(expandObject(testObjTwo))],
[expandObject(testObjOne), expandObject(flattenObject(testObjOne))],
[expandObject(testObjTwo), expandObject(flattenObject(testObjTwo))],
...[
["data.array_2[2][0]", "doubleZeroMerged"],
["data.object.two.3", "three"]
].map(([key, testVal]) => [getProperty(mergeObject(testObjOne, testObjTwo), key), testVal])
].
map(([test1, test2]) => JSON.stringify(test1) === JSON.stringify(test2))
);
// [true, true, true, true, true, true]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment