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");
2 years ago
const Result = require("../result");
2 years ago
const createCursor = require("./cursor");
2 years ago
// 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(() => {
2 years ago
// 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 {
2 years ago
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)) {
2 years ago
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 {
2 years ago
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);
2 years ago
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 ?? {};
2 years ago
return { isRecursive, allowErrors, hasChildKeys, isLeaf, args };
}
2 years ago
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 {
2 years ago
... analyzeSubquery(childCursor.query),
schemaKey: schemaKey,
handler: handler
};
}
2 years ago
function assignErrorPath(error, cursor) {
2 years ago
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
2 years ago
error.path = cursor.queryPath;
error.message = error.message + ` (${cursor.toPathString()})`;
2 years ago
}
}
// MARKER: build a sample todo-list schema for testing out fetches, mutations, and combinations of them, including on collections
2 years ago
function evaluate(cursor, context) {
// map query object -> result object
2 years ago
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 {
2 years ago
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
2 years ago
return Result.wrapAsync(() => maybeCall(handler, [ args, context ], cursor.schema));
}).then((result) => {
2 years ago
if (result.isOK) {
let value = result.value();
return Promise.try(() => {
if (!isLeaf && value != null) {
let effectiveSubquery = (isRecursive)
2 years ago
? { ... cursor.query, ... subquery }
2 years ago
: subquery;
return mapMaybeArray(value, (item, i) => {
2 years ago
// 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?
2 years ago
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);
2 years ago
});
} else {
// null / undefined are returned as-is, so are leaves
return value;
}
}).then((evaluated) => {
2 years ago
// FIXME: Verify that this is still necessary here
2 years ago
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 {
2 years ago
throw error;
}
2 years ago
}
2 years ago
}).tapCatch((error) => {
// FIXME: Chain properly
2 years ago
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);
}
};
2 years ago
let cursor = createCursor({
query: query,
schema: options.schema
});
2 years ago
// FIXME: Currently, top-level errors do not get a path property assigned to them, because that assignment happens on nested calls above
2 years ago
return evaluate(cursor, combinedContext);
}
};
};
2 years ago
module.exports.markAcceptableError = function (error) {
return {
__dlayerAcceptableError: true,
inner: error
};
};