"use strict" ;
const util = require ( "util" ) ;
const range = require ( "range" ) . range ;
const fromEntries = require ( "fromentries" ) ;
const { validateArguments , validateValue } = require ( "@validatem/core" ) ;
const isArray = require ( "@validatem/is-array" ) ;
const isPlainObject = require ( "@validatem/is-plain-object" ) ;
const isBoolean = require ( "@validatem/is-boolean" ) ;
const defaultTo = require ( "@validatem/default-to" ) ;
const removeNullishItems = require ( "@validatem/remove-nullish-items" ) ;
const virtualProperty = require ( "@validatem/virtual-property" ) ;
const wrapPath = require ( "@validatem/wrap-path" ) ;
/* NOTE: In some cases below, we explicitly check for `undefined` only, rather than for both `undefined` and `null`. This is to allow explicitly overriding existent values with `null` during a merge. */
// FIXME: Update API to provide a mergeValue function to custom merging functions, for creating combinators (like anyProperty)
// FIXME: Prevent prototype pollution
// FIXME: Figure out an ergonomic way to do nested array merging (ie. flattening)
// FIXME: Figure out a better way to pass around the options, that doesn't 'leak' to custom functions
// TODO: Add an option for removing deleted keys (rather than just setting them to `undefined`)
// FIXME: Make sure to explain the purpose of this in the documentation (multi-step merging, like in zapdb)
let DeleteValue = Symbol ( "DeleteValue" ) ;
let Template = Symbol ( "Template" ) ;
function wrapValidationPath ( basePathSegments , lastProperty , rules ) {
let combinedPath = basePathSegments . concat ( virtualProperty ( lastProperty ) ) ;
return wrapPath ( combinedPath , rules ) ;
}
function mapToObject ( items , mapper ) {
return fromEntries ( items . map ( mapper ) ) ;
}
function combineKeys ( ... objects ) {
let allKeys = new Set ( ) ;
for ( let object of objects ) {
for ( let key of Object . keys ( object ) ) {
allKeys . add ( key ) ;
}
}
return Array . from ( allKeys ) ;
}
let optionalArray = [ defaultTo ( [ ] ) , isArray ] ;
let optionalObject = [ defaultTo ( { } ) , isPlainObject ] ;
function mergeArray ( subTemplate , aInput , bInput , path , options ) {
let aItems = validateValue ( aInput , wrapValidationPath ( path , "a" , [ optionalArray ] ) ) ;
let bItems = validateValue ( bInput , wrapValidationPath ( path , "b" , [ optionalArray ] ) ) ;
let valueRule = subTemplate [ 0 ] ;
if ( valueRule == null ) {
/* No object merging rule specified, so just concatenate the items. */
return aItems . concat ( bItems ) ;
} else {
/* Object merging rule specified, so we should invoke that merging rule for each pair of objects. */
let itemCount = Math . max ( aItems . length , bItems . length ) ;
return range ( 0 , itemCount ) . map ( ( i ) => {
return mergeValue ( valueRule , aItems [ i ] , bItems [ i ] , path . concat ( [ i ] ) , options ) ;
} ) ;
}
}
function mergeObject ( subTemplate , aInput , bInput , path , options ) {
let a = validateValue ( aInput , wrapValidationPath ( path , "a" , [ optionalObject ] ) ) ;
let b = validateValue ( bInput , wrapValidationPath ( path , "b" , [ optionalObject ] ) ) ;
let allKeys = combineKeys ( a , b , subTemplate ) ;
// FIXME: Remove keys with an `undefined` value, also for items in array
return mapToObject ( allKeys , ( key ) => {
let rule = subTemplate [ key ] ;
let value = mergeValue ( rule , a [ key ] , b [ key ] , path . concat ( [ key ] ) , options ) ;
return [ key , value ] ;
} ) ;
}
function mergeValue ( rule , a , b , path , options ) {
let effectiveA = ( a === DeleteValue ) ? undefined : a ;
if ( b === DeleteValue ) {
if ( options . consumeDeleteNodes ) {
return undefined ;
} else {
return DeleteValue ;
}
} else if ( effectiveA === undefined && b === undefined ) {
// Make sure we don't invoke mergeArray/mergeObject/etc. in this case, as that would result in newly-created arrays/objects, which can interfere with eg. explicit deletions
// FIXME: Verify that this doesn't break *other* usecases
return undefined ;
} else if ( rule == null ) {
if ( b !== undefined ) {
return b ;
} else {
return a ;
}
} else if ( typeof rule === "function" ) {
if ( Template in rule ) {
// This is another merge-by-template merger, the user is attempting to compose them together, so we will just apply the template directly
return mergeValue ( rule [ Template ] , a , b , path , options ) ;
} else {
if ( effectiveA === undefined ) {
return b ;
} else if ( b === undefined ) {
return a ;
} else {
return rule ( effectiveA , b , path , options ) ; // FIXME: Do we want to pass the original A to the function instead?
}
}
} else if ( typeof rule === "object" ) {
if ( Array . isArray ( rule ) ) {
return mergeArray ( rule , effectiveA , b , path , options ) ;
} else {
return mergeObject ( rule , effectiveA , b , path , options ) ;
}
} else {
throw new Error ( ` Unrecognized rule: ${ util . inspect ( rule ) } ` ) ;
}
}
module . exports = {
DeleteValue : DeleteValue , // FIXME: Test this
Recursive : function ( mergeFunc ) {
return {
_ _mergeByTemplateOperation : "recursive" ,
mergeFunc : mergeFunc
} ;
} ,
createMerger : function createMerger ( _template , _options ) {
let [ template , options ] = validateArguments ( arguments , {
template : [ ] ,
options : [ optionalObject , {
consumeDeleteNodes : [ defaultTo ( true ) , isBoolean ]
} ]
} ) ;
let merger = function merge ( _items , _options ) {
let [ items , mergeOptions ] = validateArguments ( arguments , {
items : [ isArray , removeNullishItems ] ,
options : {
// FIXME: Deduplicate this with the options validation above
consumeDeleteNodes : [ isBoolean ]
}
} ) ;
let effectiveOptions = Object . assign ( { } , options , mergeOptions ) ;
return items . slice ( 1 ) . reduce ( ( merged , item ) => {
return mergeValue ( template , merged , item , [ ] , effectiveOptions ) ;
} , items [ 0 ] ) ;
} ;
merger [ Template ] = template ;
return merger ;
} ,
anyProperty : function ( template ) {
/* Used for cases where an object is used like a key->value map */
return function merge ( aInput , bInput , path , options ) {
let a = validateValue ( aInput , wrapValidationPath ( path , "a" , [ optionalObject ] ) ) ;
let b = validateValue ( bInput , wrapValidationPath ( path , "b" , [ optionalObject ] ) ) ;
let allKeys = combineKeys ( a , b ) ;
return mapToObject ( allKeys , ( key ) => {
let value = mergeValue ( template , a [ key ] , b [ key ] , path . concat ( [ key ] ) , options ) ;
return [ key , value ] ;
} ) ;
} ;
}
} ;