"use strict" ;
const Promise = require ( "bluebird" ) ;
const mapObject = require ( "map-obj" ) ;
const Result = require ( "../result" ) ;
// TODO: Bounded/unbounded recursion
// TODO: context
// TODO: $required query predicate
// TODO: Lazy query objects, which get initialized by calling a function that gets the parent object as an argument? This would not be serializable over the network!
// FIXME: $getProperty, $getPropertyPath, maybe $resolveObject/$query?
// FIXME: Allow setting an evaluation depth limit for queries, to limit eg. recursion
// FIXME: recurseDepth, recurseLabel/recurseGoto
/ * R e c u r s i o n d e s i g n :
When setting ` $ recurse: true ` on a child property , the parent schema gets duplicated with the child schema merged into it , and the resulting combined schema is used for the recursive fetching . Because the child schema can explicitly set properties to false , this allows for both "fetch in parent but not in recursed children" cases ( true in parent , false in child ) and "fetch in recursed children but not in parent" cases ( unspecified or false in parent , true in child ) .
The schema merging will eventually become deep - merging , when multi - level recursion is implemented ( ie . the possibility to recurse indirectly ) .
* /
const specialKeyRegex = /^\$[^\$]/ ;
function stringifyPath ( queryPath , schemaPath ) {
return queryPath
. map ( ( segment , i ) => {
if ( segment === schemaPath [ i ] ) {
return segment ;
} else {
// This is used for representing aliases, showing the original schema key in brackets
return ` ${ segment } [ ${ schemaPath [ i ] } ] ` ;
}
} )
. join ( " -> " ) ;
}
function maybeCall ( value , args , thisContext ) {
return Promise . try ( ( ) => {
// FIXME: Only do this for actual fetch requests
let getter = ( typeof value === "object" && value != null && value . $get != null )
? value . $get
: value ;
if ( typeof getter === "function" ) {
return getter . call ( thisContext , ... args ) ;
} else {
return getter ;
}
} ) ;
}
function isObject ( value ) {
// FIXME: Replace this with a more sensible check, like is-plain-object
return ( value != null && typeof value === "object" && ! Array . isArray ( value ) ) ;
}
// TODO: Move to separate package, decide whether to keep the nested array detection or not - that should probably just be part of the handler?
function mapMaybeArray ( value , handler ) {
// NOTE: This is async!
if ( Array . isArray ( value ) ) {
return Promise . map ( value , ( item , i ) => {
if ( Array . isArray ( item ) ) {
throw new Error ( ` Encountered a nested array, which is not allowed; maybe you forgot to flatten it? ` ) ;
} else {
return handler ( item , i ) ;
}
} ) ;
} else {
return handler ( value ) ;
}
}
/ * P o s s i b l e v a l u e s o f a s c h e m a p r o p e r t y :
true , null , object with only special keys ( but not $recurse ) -- fetch value and return it as - is
false -- do not fetch value at all
object with $recurse -- recursively fetch , optionally with extra keys to fetch ( or ignore ) for recursed children only , inheriting the rest of the schema from the parent
object with regular non - special keys -- fetch object and continue fetching into child properties according to the schema
a "special key" is any key that is prefixed with $ - they are used to provide additional parameters to dlayer , and cannot be used for business logic keys
* /
function asyncMapObject ( object , handler ) {
return Promise . props ( mapObject ( object , handler ) ) ;
}
function analyzeSubquery ( subquery ) {
let isRecursive = ( subquery ? . $recurse === true ) ;
let allowErrors = ( subquery ? . $allowErrors === true ) ;
let hasChildKeys = isObject ( subquery ) && Object . keys ( subquery ) . some ( ( key ) => ! specialKeyRegex . test ( key ) ) ;
let isLeaf = ( subquery === true || subquery === null || ( ! hasChildKeys && ! isRecursive ) ) ;
let args = subquery ? . $arguments ? ? { } ;
return { isRecursive , allowErrors , hasChildKeys , isLeaf , args } ;
}
function analyzeQueryKey ( schemaObject , queryObject , queryKey ) {
let subquery = queryObject [ queryKey ] ;
let schemaKey = subquery ? . $key ? ? queryKey ; // $key is for handling aliases
let handler = schemaObject [ schemaKey ] ? ? schemaObject . $anyKey ;
return {
... analyzeSubquery ( subquery ) ,
schemaKey : schemaKey ,
handler : handler
} ;
}
function assignErrorPath ( error , queryPath , schemaPath ) {
if ( error . path == null ) {
// Only assign the path if it hasn't already happened at a deeper level; this is a recursive function after all
error . path = queryPath ;
error . message = error . message + ` ( ${ stringifyPath ( queryPath , schemaPath ) } ) ` ;
}
}
function evaluate ( schemaObject , queryObject , context , queryPath , schemaPath ) {
// map query object -> result object
return asyncMapObject ( queryObject , ( queryKey , subquery ) => {
let shouldFetch = ( subquery !== false ) ;
if ( ! shouldFetch || specialKeyRegex . test ( queryKey ) ) {
// When constructing the result object, we only care about the 'real' keys, not about special meta-keys like $key; those get processed in the actual resolution logic itself.
return mapObject . mapObjectSkip ;
} else {
let { schemaKey , handler , args , isRecursive , allowErrors , isLeaf } = analyzeQueryKey ( schemaObject , queryObject , queryKey ) ;
if ( handler != null ) {
let handlingQueryPath = queryPath . concat ( [ queryKey ] ) ;
let handlingSchemaPath = schemaPath . concat ( [ schemaKey ] ) ;
let promise = Promise . try ( ( ) => {
// This calls the data provider in the schema
return Result . wrapAsync ( ( ) => maybeCall ( handler , [ args , context ] , schemaObject ) ) ;
} ) . then ( ( result ) => {
if ( result . isOK ) {
let value = result . value ( ) ;
return Promise . try ( ( ) => {
if ( ! isLeaf && value != null ) {
let effectiveSubquery = ( isRecursive )
? { ... queryObject , ... subquery }
: subquery ;
return mapMaybeArray ( value , ( item , i ) => {
if ( i != null ) {
let elementQueryPath = handlingQueryPath . concat ( [ i ] ) ;
let elementSchemaPath = handlingSchemaPath . concat ( [ i ] ) ;
return evaluate ( item , effectiveSubquery , context , elementQueryPath , elementSchemaPath ) ;
} else {
return evaluate ( item , effectiveSubquery , context , handlingQueryPath , handlingSchemaPath ) ;
}
} ) ;
} else {
// null / undefined are returned as-is, so are leaves
return value ;
}
} ) . then ( ( evaluated ) => {
// FIXME: Verify that this is still necessary here
if ( allowErrors ) {
return Result . ok ( evaluated ) ;
} else {
return evaluated ;
}
} ) ;
} else {
let error = result . error ( ) ;
if ( error . _ _dlayerAcceptableError === true ) {
if ( allowErrors === true ) {
return Result . error ( error . inner ) ;
} else {
throw error . inner ;
}
} else {
throw error ;
}
}
} ) . tapCatch ( ( error ) => {
// FIXME: Chain properly
assignErrorPath ( error , handlingQueryPath , handlingSchemaPath ) ;
} ) ;
return [ queryKey , promise ] ;
} else {
throw new Error ( ` No key ' ${ schemaKey } ' exists in the schema ` ) ;
}
}
} ) ;
}
module . exports = function createDLayer ( options ) {
// options = { schema, makeContext }
return {
query : function ( query , context ) {
let generatedContext = ( options . makeContext != null )
? options . makeContext ( )
: { } ;
function getProperty ( object , property , args = { } ) {
// FIXME: Validatem
if ( object == null ) {
throw new Error ( ` Empty object passed ` ) ;
}
if ( property in object ) {
return maybeCall ( object [ property ] , [ args , combinedContext ] , object ) ;
} else {
// FIXME: Better error message with path
throw new Error ( ` No key ' ${ property } ' exists in the schema ` ) ;
}
}
let combinedContext = {
... generatedContext ,
... context ,
// FIXME: Figure out a way to annotate errors here with the path at which they occurred, *and* make clear that it was an internal property lookup
$getProperty : getProperty ,
$getPropertyPath : function ( object , propertyPath ) {
let parsedPath = ( typeof propertyPath === "string" )
? propertyPath . split ( "." )
: propertyPath ;
return Promise . reduce ( parsedPath , ( currentObject , pathSegment ) => {
if ( currentObject != null ) {
return getProperty ( currentObject , pathSegment ) ;
} else {
// Effectively null-coalescing
return null ;
}
} , object ) ;
}
} ;
// FIXME: Currently, top-level errors do not get a path property assigned to them, because that assignment happens on nested calls above
return evaluate ( options . schema , query , combinedContext , [ ] , [ ] ) ;
}
} ;
} ;
module . exports . markAcceptableError = function ( error ) {
return {
_ _dlayerAcceptableError : true ,
inner : error
} ;
} ;