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.
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
2 years ago
|
"use strict";
|
||
|
|
||
|
const Promise = require("bluebird");
|
||
|
const mapObject = require("map-obj");
|
||
11 months ago
|
const syncpipe = require("syncpipe");
|
||
2 years ago
|
|
||
11 months ago
|
const Result = require("../result-old");
|
||
2 years ago
|
const createCursor = require("./cursor");
|
||
11 months ago
|
const deepMerge = require("./deep-merge");
|
||
|
const loadModules = require("./load-modules");
|
||
2 years ago
|
|
||
2 years ago
|
// TODO: Bounded/unbounded recursion
|
||
11 months ago
|
// TODO: Should we allow async context generation? Both in root schema *and* in modules
|
||
2 years ago
|
// 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
|
||
|
|
||
11 months ago
|
/* Process design:
|
||
11 months ago
|
|
||
|
- 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.
|
||
|
|
||
|
*/
|
||
|
|
||
2 years ago
|
/* 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);
|
||
2 years ago
|
} else {
|
||
2 years ago
|
return getter;
|
||
2 years ago
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
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) => {
|
||
2 years ago
|
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);
|
||
2 years ago
|
}
|
||
|
});
|
||
|
} 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);
|
||
2 years ago
|
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
|
}
|
||
|
|
||
11 months ago
|
function makeInstruction(cursor, queryKey) {
|
||
|
let childCursor = cursor.child(queryKey);
|
||
|
let handler = childCursor.schema ?? cursor.schema.$anyKey;
|
||
2 years ago
|
|
||
|
return {
|
||
2 years ago
|
... analyzeSubquery(childCursor.query),
|
||
11 months ago
|
cursor: childCursor,
|
||
2 years ago
|
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
|
}
|
||
|
}
|
||
|
|
||
11 months ago
|
// TODO: build a sample todo-list schema for testing out fetches, mutations, and combinations of them, including on collections
|
||
|
|
||
11 months ago
|
function makeEnvironment(context, getContextForModule) {
|
||
11 months ago
|
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
|
||
11 months ago
|
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));
|
||
|
}
|
||
11 months ago
|
}).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);
|
||
2 years ago
|
} else {
|
||
11 months ago
|
throw error.inner;
|
||
2 years ago
|
}
|
||
11 months ago
|
} else {
|
||
|
throw error;
|
||
2 years ago
|
}
|
||
11 months ago
|
}
|
||
|
}).tapCatch((error) => {
|
||
|
// FIXME: Chain properly
|
||
|
assignErrorPath(error, cursor);
|
||
|
});
|
||
|
} else {
|
||
|
throw new Error(`No key '${schemaKey}' 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
|
||
2 years ago
|
});
|
||
|
|
||
11 months ago
|
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;
|
||
2 years ago
|
} else {
|
||
11 months ago
|
// 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;
|
||
11 months ago
|
|
||
11 months ago
|
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;
|
||
|
})
|
||
|
];
|
||
2 years ago
|
}
|
||
11 months ago
|
});
|
||
|
}
|
||
|
|
||
|
return applyRules;
|
||
2 years ago
|
}
|
||
|
|
||
|
module.exports = function createDLayer(options) {
|
||
|
// options = { schema, makeContext }
|
||
|
|
||
11 months ago
|
let loaded = loadModules(options.modules ?? []);
|
||
11 months ago
|
let schema = deepMerge(loaded.root, options.schema);
|
||
|
|
||
2 years ago
|
return {
|
||
|
query: function (query, context) {
|
||
|
let generatedContext = (options.makeContext != null)
|
||
|
? options.makeContext()
|
||
|
: {};
|
||
|
|
||
|
function getProperty(object, property, args = {}) {
|
||
11 months ago
|
// TODO: Should this allow a single-argument, property-string-only variant for looking up properties on self?
|
||
2 years ago
|
// 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`);
|
||
|
}
|
||
11 months ago
|
}
|
||
|
|
||
|
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;
|
||
|
}
|
||
|
}
|
||
2 years ago
|
|
||
|
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);
|
||
11 months ago
|
},
|
||
|
$make: function (typeID, args) {
|
||
|
return make(typeID, args, true);
|
||
|
},
|
||
|
$maybeMake: function (typeID, args) {
|
||
|
return make(typeID, args, false);
|
||
2 years ago
|
}
|
||
|
};
|
||
|
|
||
2 years ago
|
let cursor = createCursor({
|
||
|
query: query,
|
||
11 months ago
|
schema: schema
|
||
2 years ago
|
});
|
||
|
|
||
11 months ago
|
let evaluate = makeEnvironment(combinedContext, loaded.makeContextFactory(combinedContext));
|
||
11 months ago
|
|
||
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
|
||
11 months ago
|
return evaluate(cursor);
|
||
2 years ago
|
}
|
||
|
};
|
||
|
};
|
||
|
|
||
2 years ago
|
module.exports.markAcceptableError = function (error) {
|
||
|
return {
|
||
|
__dlayerAcceptableError: true,
|
||
|
inner: error
|
||
|
};
|
||
|
};
|
||
|
|
||
2 years ago
|
|