You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

336 lines
13 KiB
JavaScript

"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");
const InvalidObject = require("./invalid-object");
// 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
// TODO: Throw a useful error when trying to $make a non-existent type
// TODO: Refactor using stand-alone utility functions
/* Process design:
- 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.
*/
/* Recursion design:
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, baseContext, getContextForModule, thisContext) {
return Promise.try(() => {
// TODO" Is the $get thing still relevant?
// FIXME: Only do this for actual fetch requests
let getter = (typeof value === "object" && value != null && value.$get != null)
? value.$get
: value;
let actualGetter = (getter.__moduleID != null)
? getter.func
: getter;
if (typeof actualGetter === "function") {
let applicableContext = (getter.__moduleID != null)
// Defined in a module
? getContextForModule(getter.__moduleID)
// Defined in the root schema
: baseContext;
return actualGetter.call(thisContext, args, applicableContext);
} else {
return actualGetter;
}
});
}
function isObject(value) {
// FIXME: Replace this with a more sensible check, like is-plain-object
return (value != null && typeof value === "object" && !Array.isArray(value));
}
/* Possible values of a schema property:
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 { handler, args, allowErrors, cursor } = instruction;
if (handler != null) {
return Promise.try(() => {
// This calls the data provider in the schema
return Result.wrapAsync(() => maybeCall(handler, args, context, getContextForModule, 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.filter(value, (object) => {
return (object !== InvalidObject);
}).map((item, i) => {
let itemCursor = cursor
.child(i, i)
.override({
query: query,
schema: item
});
return applyRules(itemCursor);
});
} else if (value !== InvalidObject) {
let itemCursor = cursor
.override({
query: query,
schema: value
});
return applyRules(itemCursor);
} else {
return undefined;
}
}
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()
: {};
// NOTE: The code order is important here - there's a cyclical reference between getProperty and the combinedContext, and only getProperty gets hoisted!
let combinedContext = {
... generatedContext,
... context,
$getModuleContext: getModuleContext,
// 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 getContextForModule = loaded.makeContextFactory(combinedContext);
function getModuleContext(moduleName) {
// NOTE: This is an escape hatch to access a module's context from outside of that module; you should not normally need this!
if (loaded.nameToID.has(moduleName)) {
let moduleID = loaded.nameToID.get(moduleName);
console.log({moduleName, moduleID});
return getContextForModule(moduleID);
} else {
throw new Error(`No module named '${moduleName}' has been loaded`);
}
}
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, getContextForModule, object);
} else {
// FIXME: Better error message with path
throw new Error(`No key '${property}' exists in the schema`);
}
}
async function make(typeID, args, existenceRequired) {
let type = loaded.types[typeID];
if (type == null) {
if (existenceRequired === true) {
throw new Error(`No type named '${typeID}' exists`);
} else {
return;
}
} else {
let instance = await type.func(args, getContextForModule(type.__moduleID));
if (loaded.extensions[typeID] != null && instance !== InvalidObject) {
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 cursor = createCursor({
query: query,
schema: schema
});
let evaluate = makeEnvironment(combinedContext, getContextForModule);
// 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
};
};
module.exports.InvalidObject = InvalidObject;