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.

249 lines
8.5 KiB
JavaScript

"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
/* 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 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);
}
}
/* 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 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
};
};