Last active
January 2, 2021 09:25
-
-
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]")
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// *************************************** | |
// *** 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