"use strict" ;
const Promise = require ( "bluebird" ) ;
const mapObject = require ( "map-obj" ) ;
const Result = require ( "@joepie91/result" ) ;
const createCursor = require ( "./cursor" ) ;
const deepMergeAndMap = require ( "./deep-merge-and-map" ) ;
const loadModules = require ( "./load-modules" ) ;
// TODO: Bounded/unbounded recursion
// TODO: Should we allow async context generation? Both in root schema *and* in modules
// 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
// TODO: Internal queries, but only in modules, and only as a last resort
/ * P r o c e s s d e s i g n :
- The process starts with :
1. A query tree , a nested object representing the query from the user
2. A schema tree , an abstract tree of nested objects specified by the API ; notably , the full tree is not known upfront , and parts may be discovered asynchronously or even generated dynamically
3. A cursor pointing at the root
- All of these objects are immutable , including the cursor ( a child cursor is created , you don ' t mutate the existing cursor )
- The cursor is an object that represents a specific position in the query * and * schema tree ; it has no understanding of the API structure , nor any ability to evaluate any handlers . All it does is keep track of what part of the query and schema tree we ' re currently dealing with , accounting for aliases where appropriate , and ensuring that the two pointers move in tandem .
- We recurse into the query tree according to the query the user specified , moving along in the schema tree accordingly . Branches that are specified in the schema tree but not in the query tree , will not be visited .
- At each step along the way , we have a cursor pointing at the schema item being processed currently ; that schema item can be some static subtree , or eg . a value generated by a previous handler .
- From that cursor , for each step , we generate an 'instruction' ; this is a parsed representation of that query item ' s querying rules , as well as information on what to do with the result once that item has been evaluated . This instruction is used to evaluate the relevant handler and then continue recursing with child rules ( or , in the case of an actual recursive query , duplicating the previous rules ) .
- From the result , we then generate a new cursor for the child items . And so on , and so forth .
* /
/ * 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 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 makeInstruction ( cursor , queryKey ) {
let childCursor = cursor . child ( queryKey ) ;
let handler = childCursor . schema ? ? cursor . schema . $anyKey ;
return {
... analyzeSubquery ( childCursor . query ) ,
cursor : childCursor ,
handler : handler
} ;
}
function assignErrorPath ( error , cursor ) {
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 = cursor . queryPath ;
error . message = error . message + ` ( ${ cursor . toPathString ( ) } ) ` ;
}
}
// TODO: build a sample todo-list schema for testing out fetches, mutations, and combinations of them, including on collections
function makeEnvironment ( context , getContextForModule ) {
function callHandler ( instruction ) {
// NOTE: cursor is assumed to already be the key of the child
let { schemaKey , handler , args , allowErrors , cursor } = instruction ;
if ( handler != null ) {
return Promise . try ( ( ) => {
// This calls the data provider in the schema
if ( handler . _ _moduleID != null ) {
// Defined in a module
return Result . wrapAsync ( ( ) => maybeCall ( handler . func , [ args , getContextForModule ( handler . _ _moduleID ) ] , cursor . parent . schema ) ) ;
} else {
// Defined in the root schema
return Result . wrapAsync ( ( ) => maybeCall ( handler , [ args , context ] , cursor . parent . schema ) ) ;
}
} ) . then ( ( result ) => {
if ( result . isOK ) {
return result . value ( ) ;
} 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 , cursor ) ;
} ) ;
} else {
throw new Error ( ` No key ' ${ cursor . schemaPath . at ( - 1 ) } ' exists in the schema ` ) ;
}
}
// FIXME: instruction abstraction?
function applyToResultValue ( instruction , value , query ) {
let { cursor } = instruction ;
if ( Array . isArray ( value ) ) {
return Promise . map ( value , ( item , i ) => {
let itemCursor = cursor
. child ( i , i )
. override ( {
query : query ,
schema : item
} ) ;
return applyRules ( itemCursor ) ;
} ) ;
} else {
let itemCursor = cursor
. override ( {
query : query ,
schema : value
} ) ;
return applyRules ( itemCursor ) ;
}
}
function applyRules ( cursor ) {
// map query object -> result object
return asyncMapObject ( cursor . query , ( 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 {
// FIXME: This is hacky, and should be made more ergonomic...
return [
queryKey ,
Promise . try ( async ( ) => {
let instruction = makeInstruction ( cursor , queryKey ) ;
let value = await callHandler ( instruction ) ;
let effectiveSubquery = ( instruction . isRecursive )
? { ... cursor . query , ... subquery }
: subquery ;
let finalValue = ( instruction . isLeaf || value == null )
? value
: applyToResultValue ( instruction , value , effectiveSubquery ) ;
// FIXME: We're absorbing Result.errors here, but that's a bit weird. We should probably be consistently carrying Result values throughout the implementation, and only unwrap them at the last moment?
return ( instruction . allowErrors )
? Result . ok ( finalValue )
: finalValue ;
} )
] ;
}
} ) ;
}
return applyRules ;
}
module . exports = function createDLayer ( options ) {
// options = { schema, makeContext }
let loaded = loadModules ( options . modules ? ? [ ] ) ;
let schema = deepMergeAndMap ( loaded . root , options . schema ) ;
return {
query : function ( query , context ) {
let generatedContext = ( options . makeContext != null )
? options . makeContext ( )
: { } ;
function getProperty ( object , property , args = { } ) {
// TODO: Should this allow a single-argument, property-string-only variant for looking up properties on self?
// 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 ` ) ;
}
}
function make ( typeID , args , existenceRequired ) {
let type = loaded . types [ typeID ] . func ;
if ( type == null ) {
if ( existenceRequired === true ) {
throw new Error ( ` No type named ' ${ typeID } ' exists ` ) ;
} else {
return ;
}
} else {
let instance = type ( args ) ;
if ( loaded . extensions [ typeID ] != null ) {
for ( let [ key , extension ] of Object . entries ( loaded . extensions [ typeID ] ) ) {
// TODO: Possibly make this more performant by making it possible to do an Object.assign? Or does that not matter for performance?
instance [ key ] = extension . func ;
}
}
return instance ;
}
}
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 ) ;
} ,
$make : function ( typeID , args ) {
return make ( typeID , args , true ) ;
} ,
$maybeMake : function ( typeID , args ) {
return make ( typeID , args , false ) ;
}
} ;
let cursor = createCursor ( {
query : query ,
schema : schema
} ) ;
let evaluate = makeEnvironment ( combinedContext , loaded . makeContextFactory ( combinedContext ) ) ;
// FIXME: Currently, top-level errors do not get a path property assigned to them, because that assignment happens on nested calls above
return evaluate ( cursor ) ;
}
} ;
} ;
module . exports . markAcceptableError = function ( error ) {
return {
_ _dlayerAcceptableError : true ,
inner : error
} ;
} ;