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.

250 lines
9.1 KiB
JavaScript

"use strict";
const Promise = require("bluebird");
const mapObject = require("map-obj");
const Result = require("../result");
const createCursor = require("./cursor");
// 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 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(cursor, queryKey) {
let childCursor = cursor.child(queryKey, null);
let schemaKey = childCursor.query?.$key ?? queryKey; // $key is for handling aliases
let handler = cursor.child(queryKey, schemaKey).schema ?? cursor.schema.$anyKey;
// TODO: Maybe clean up all the .child stuff here by moving the `$key` logic into the cursor implementation instead, as it seems like nothing else in dlayer needs to care about the aliasing
return {
... analyzeSubquery(childCursor.query),
schemaKey: schemaKey,
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()})`;
}
}
// MARKER: build a sample todo-list schema for testing out fetches, mutations, and combinations of them, including on collections
function evaluate(cursor, context) {
// 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 {
let { schemaKey, handler, args, isRecursive, allowErrors, isLeaf } = analyzeQueryKey(cursor, queryKey);
if (handler != null) {
let promise = Promise.try(() => {
// This calls the data provider in the schema
return Result.wrapAsync(() => maybeCall(handler, [ args, context ], cursor.schema));
}).then((result) => {
if (result.isOK) {
let value = result.value();
return Promise.try(() => {
if (!isLeaf && value != null) {
let effectiveSubquery = (isRecursive)
? { ... cursor.query, ... subquery }
: subquery;
return mapMaybeArray(value, (item, i) => {
// NOTE: We're adding `i` to the query path for user feedback purposes, but we're not *actually* diving down into that property on the query object; the queryOverride doesn't just handle recursion, it also ensures that the 'original' subquery is passed in regardless of what the path suggests
// TODO: schemaOverride here is used to pass in the (asynchronously/lazily) resolved result, which the cursor implementation wouldn't have access to otherwise; need to somehow make it clearer in the API design that the automatic 'schema navigation' is only used for simple objects - maybe not call it an 'override' but instead just something like newSchema and newQuery?
let subCursor = (i != null)
? cursor
.child(queryKey, schemaKey)
.child(i, i, {
queryOverride: effectiveSubquery,
schemaOverride: item
})
: cursor
.child(queryKey, schemaKey, {
queryOverride: effectiveSubquery,
schemaOverride: item
});
return evaluate(subCursor, context);
});
} 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, cursor.child(queryKey, schemaKey));
});
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);
}
};
let cursor = createCursor({
query: query,
schema: options.schema
});
// 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, combinedContext);
}
};
};
module.exports.markAcceptableError = function (error) {
return {
__dlayerAcceptableError: true,
inner: error
};
};