for-each-safe
Version:
Recursively loop through the elements of a 'collection' (an array, an array-like object, or a plain object) and invoke 'callbackFn' for each element while protected against circular references.
247 lines (228 loc) • 7.24 kB
JavaScript
const forEach = require('./utils/core.forEach')
const isPlainObject = require('lodash.isplainobject')
const merge = require('lodash.merge')
const LOOP_CONTINUE = '0' // Alternate: `undefined`
const LOOP_BREAK_CURRENT = '10' // Alternate: `false`
const LOOP_BREAK_ALL = '11'
const LOOP_SKIP_CHILDREN = '20'
function circularStats(value, ancestry) {
function detectCircularFindIndex(ancestor, ancestorindex) {
return Object.is(value, ancestor.value)
}
var ancestorIndex = ancestry.findIndex(detectCircularFindIndex)
var isCircular = ancestorIndex > -1
var ancestorPath
if (isCircular) {
ancestorPath = ancestry[ancestorIndex].path
}
return {
ancestorIndex: ancestorIndex,
ancestorPath: ancestorPath,
isCircular: isCircular
}
}
/**
* Loops recursively through an array or plain object with optional
* consideration for circular references that is enabled by default.
*/
function forEachSafe(collection, callbackFn, options) {
/*
* Handle option processing so that it only needs to be done once before
* passing it to the internal recursive loop.
*/
const DEFAULTS = {
enableCircularReferenceChecking: true,
enablePathArray: false,
skipCircularReferences: false,
useDotNotationOnKeys: true
}
const SETTINGS = merge({}, DEFAULTS, options)
/*
* Light value checking/validation/coercing. Prevents the need to check in
* each iteration.
* TODO: A more permanent solution should be checking / validating options
* before they are merged.
*/
if (typeof SETTINGS.enableCircularReferenceChecking !== 'boolean') {
SETTINGS.enableCircularReferenceChecking =
DEFAULTS.enableCircularReferenceChecking
}
if (typeof SETTINGS.enablePathArray !== 'boolean') {
SETTINGS.enablePathArray = DEFAULTS.enablePathArray
}
if (typeof SETTINGS.skipCircularReferences !== 'boolean') {
SETTINGS.skipCircularReferences = DEFAULTS.skipCircularReferences
}
if (typeof SETTINGS.useDotNotationOnKeys !== 'boolean') {
SETTINGS.useDotNotationOnKeys = DEFAULTS.useDotNotationOnKeys
}
forEachLoop(
collection,
callbackFn,
'',
SETTINGS.enablePathArray ? [] : null,
[],
SETTINGS,
Array.isArray(collection),
isPlainObject(collection)
)
}
function forEachLoop(
value,
fn,
path,
pathArray,
ancestry,
settings,
valueIsArray,
valueIsPlainObject
) {
var checkingIsEnabled = settings.enableCircularReferenceChecking
var pathArrayIsEnabled = settings.enablePathArray
var skipCircularReferences = settings.skipCircularReferences
var useDotNotationOnKeys = settings.useDotNotationOnKeys
var loopReturnCode
if (valueIsArray || valueIsPlainObject) {
/*
* Add a "family member" to the "family tree".
*
* Work on a copy of ancestry, because if we work on a reference, we would
* have to detect and remove siblings.
*/
// There has to be a more performant way to make a shallow copy.
ancestry = ancestry.concat()
ancestry.push({
path: path,
value: value
})
/*
* Loop through each member of the collection.
*/
forEach(value, function forEachCollectionIteratee(
childValue,
keyOrIndex,
parentCollection
) {
var childLoopReturnCode
var childValueIsCircular
var childValueMayLoop
var childValuePostFn
var childValuePostFnIsArray
var childValuePostFnIsObject
var cStats
var cStatsPostFn
var deepPath
var deepPathArray
var fnReturnCode
var skipFnCall
var skipLoop
childValueIsCircular = checkingIsEnabled ? false : null
childValueMayLoop = Array.isArray(childValue) || isPlainObject(childValue)
skipFnCall = false
skipLoop = false
if (valueIsArray) {
deepPath = path + '[' + keyOrIndex + ']'
} else if (valueIsPlainObject) {
if (useDotNotationOnKeys) {
deepPath = path ? path + '.' + keyOrIndex : keyOrIndex
} else {
deepPath = path + '[' + keyOrIndex + ']'
}
}
if (pathArrayIsEnabled) {
deepPathArray = pathArray.concat()
deepPathArray.push(keyOrIndex)
} else {
deepPathArray = null
}
/*
* Prevent the cost of the circular reference check on values that are
* not iterated through.
*/
if (childValueMayLoop && checkingIsEnabled) {
cStats = circularStats(childValue, ancestry)
childValueIsCircular = cStats.isCircular
if (childValueIsCircular) {
if (skipCircularReferences) {
skipFnCall = true
} else {
childValue = ['[CircularReference]', cStats.ancestorPath]
}
}
}
if (!skipFnCall) {
fnReturnCode = fn.call(
parentCollection,
childValue,
keyOrIndex,
parentCollection,
deepPath,
deepPathArray,
childValueIsCircular
)
}
/*
* - If the code is a number, convert it to a string
* - If the code is undefined, convert it to `LOOP_CONTINUE`
* - If the code is false, convert it to `LOOP_BREAK_CURRENT`
*/
if (Number.isFinite(fnReturnCode)) {
fnReturnCode = fnReturnCode.toString()
} else if (typeof fnReturnCode === 'undefined') {
fnReturnCode = LOOP_CONTINUE
} else if (fnReturnCode === false) {
fnReturnCode = LOOP_BREAK_CURRENT
}
/*
* Break out of the current loop if the fn return code says so.
*/
if (fnReturnCode === LOOP_BREAK_CURRENT) {
return false
}
/*
* Break out of the current loop if the fn return code says so AND
* tell all ancestor loops that they should break.
*/
if (fnReturnCode === LOOP_BREAK_ALL) {
loopReturnCode = fnReturnCode
return false
}
/*
* Get the value at `keyOrIndex` again, because it may have been
* changed by `fn` or a sibling `forEachLoop`.
*/
childValuePostFn = parentCollection[keyOrIndex]
childValuePostFnIsArray = Array.isArray(childValuePostFn)
childValuePostFnIsObject = isPlainObject(childValuePostFn)
/*
* While skipping the function call may be optional, skipping the loop
* iteration with a circular reference is NOT optional.
*/
if (childValuePostFnIsArray || childValuePostFnIsObject) {
cStatsPostFn = circularStats(childValuePostFn, ancestry)
skipLoop = cStatsPostFn.isCircular
if (!skipLoop && fnReturnCode !== LOOP_SKIP_CHILDREN) {
childLoopReturnCode = forEachLoop(
childValuePostFn,
fn,
deepPath,
deepPathArray,
ancestry,
settings,
childValuePostFnIsArray,
childValuePostFnIsObject
)
/*
* Break out of the current loop if the loop return code says so.
*/
if (childLoopReturnCode === LOOP_BREAK_ALL) {
loopReturnCode = childLoopReturnCode
return false
}
}
}
})
}
return loopReturnCode
}
module.exports = forEachSafe