Source: composer-runtime/lib/queryexecutor.js

/*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

'use strict';

const jsonata = require('jsonata');
const Logger = require('composer-common').Logger;
const Relationship = require('composer-common').Relationship;
const Resource = require('composer-common').Resource;

const LOG = Logger.getLog('QueryEngine');

/**
 * Design documentation:
 *
 * The aim of this class to execute queries (currently JSONata expressions) on a
 * resource or a set of resources. In order for this query support to be useful,
 * the queries must be able to navigate relationships between resources. For example,
 * I might want to execute a query of "find all the animals which are in a field,
 * where that field is owned by a business, where that business is run by a farmer
 * with the email address alice@farmers.com".
 *
 * We do not want to eagerly resolve all visible relationships, as this is slow.
 * Most business networks are large and complex, with many relationships between
 * resources, and additionally those relationships can be circular.
 *
 * Ideally, we would only resolve relationships that are needed in order to
 * successfully execute the query. We can do this by creating "special" properties
 * on the relationship object for each of the properties that should exist on the
 * target resource. When the "special" property is accessed by the query, a function
 * can be executed to resolve that relationship and replace it with the target
 * resource.
 *
 * Unfortunately, resolving a relationship is an asynchronous operation, because
 * we have to perform network I/O. JSONata (our expression runtime) does not, at
 * the time of writing, have any support for handling asynchronous values or
 * promises that will be resolved with values.
 *
 * The code below attempts to work around this by executing the following algorithm:
 *
 * 1)  An empty array of accessed relationships is created.
 *
 * 2)  The resource or resources being queried are modified so that all of their
 *     relationships are augmented with "special" properties. These properties
 *     execute a function when they are read. That function then adds the properties
 *     owning relationship to the array defined in 1).
 *
 * 3)  The JSONata expression is evaluated against the resource or resources. The
 *     evaluation will return a value.
 *
 * 4)  If the array defined in 1) is empty, no relationships were accessed, and
 *     we can safely return the result of 3) and stop processing.
 *
 * 5)  If the array defined in 1) is not empty, then we must iterate over that array.
 *     For each accessed relationship in the array:
 *
 * 5a) The relationship is resolved to its target resource.
 *
 * 5b) The resolved resource is modified as per 2) so that any relationships in the
 *     resolved resource are also augumented with "special" properties.
 *
 * 5c) The relationship is replaced with the resolved resource.
 *
 * 5d) The accessed relationship is removed from the array defined in 1).
 *
 * 6)  Now that all accessed relationships have been resolved, we repeat step 3).
 *     Note that new relationships may be accessed, so we may repeat steps 3) to
 *     6) multiple times until we can return in 4).
 *
 * This is inefficient as we may have to evaluate the JSONata expression multiple
 * times until all required relationships are resolved. Currently, the resolver
 * uses a cache of resources to save on expensive registry lookups. A future
 * improvement to this algorithm could be to keep a cache of queries and a list
 * of the relationships that had to be resolved in order to process each query.
 * Those relationships could then be eagerly resolved upfront for any subsequent
 * executions of the same query.
 */

/**
 * A class for executing queries using JSONata expressions against resources.
 * @protected
 */
class QueryExecutor {

    /**
     * Constructor.
     * @param {Resolver} resolver The resolver to use.
     */
    constructor(resolver) {
        const method = 'constructor';
        LOG.entry(method, resolver);
        this.resolver = resolver;
        LOG.exit(method);
    }

    /**
     * Query all of the specified resources using the given JSONata expression.
     * @param {string} expression The JSONata expression.
     * @param {Resource[]} resources The resource to query.
     * @return {Promise} A promise that will be resolved with the results of the
     * query, or rejected with an error.
     */
    queryAll(expression, resources) {
        const method = 'queryAll';
        LOG.entry(method, expression, resources);

        // Compile the expression.
        LOG.debug(method, 'Compiling JSONata expression');
        const compiledExpression = jsonata(expression);
        LOG.debug(method, 'Compiled JSONata expression');

        // Prepare the root resources.
        let accessedRelationships = [];
        let cachedResources = new Map();
        resources.forEach((resource) => {
            LOG.debug(method, 'Preparing resource', resource.getFullyQualifiedIdentifier());
            let fqi = resource.getFullyQualifiedIdentifier();
            cachedResources.set(fqi, resource);
            this.prepareResource(resource, accessedRelationships);
        });

        // Process the query by calling the recursive method that does the bulk of the work.
        let promise = Promise.resolve();
        let result = [];
        resources.forEach((resource) => {
            promise = promise.then(() => {
                LOG.debug(method, 'Executing query on resource', resource.getFullyQualifiedIdentifier());
                return this.queryInternal(compiledExpression, resource, accessedRelationships, cachedResources)
                    .then((thisResult) => {
                        LOG.debug(method, 'Executed query, adding result to list');
                        result.push(thisResult);
                    });
            });
        });
        return promise
            .then(() => {
                LOG.exit(method, result);
                return result;
            });

    }

    /**
     * Query the specified resource using the given JSONata expression.
     * @param {string} expression The JSONata expression.
     * @param {Resource} resource The resource to query.
     * @return {Promise} A promise that will be resolved with the results of the
     * query, or rejected with an error.
     */
    query(expression, resource) {
        const method = 'query';
        LOG.entry(method, expression, resource.toString());

        // Compile the expression.
        LOG.debug(method, 'Compiling JSONata expression');
        const compiledExpression = jsonata(expression);
        LOG.debug(method, 'Compiled JSONata expression');

        // Prepare the root resource.
        let accessedRelationships = [];
        let cachedResources = new Map();
        let fqi = resource.getFullyQualifiedIdentifier();
        cachedResources.set(fqi, resource);
        this.prepareResource(resource, accessedRelationships);

        // Process the query by calling the recursive method that does the bulk of the work.
        return this.queryInternal(compiledExpression, resource, accessedRelationships, cachedResources)
            .then((result) => {
                LOG.exit(method, result);
                return result;
            });

    }

    /**
     * @private
     * @typedef {Object} FoundRelationship
     * @property {Relationship} relationship The relationship.
     * @property {function} resolve The function to set the resolved value.
     */

    /**
     * Query the specified resource using the given JSONata expression.
     * @private
     * @param {Object} compiledExpression The compiled JSONata expression.
     * @param {Resource} resource The resource to query.
     * @param {FoundRelationship[]} accessedRelationships The working array of accessed relationships.
     * @param {Map} cachedResources The cache of resources.
     * @return {Promise} A promise that will be resolved with the results of the
     * query, or rejected with an error.
     */
    queryInternal(compiledExpression, resource, accessedRelationships, cachedResources) {
        const method = 'queryInternal';
        LOG.entry(method, compiledExpression, resource, accessedRelationships, cachedResources);

        // Evaluate the expression.
        LOG.debug(method, 'Evaluating JSONata expression');
        let result = compiledExpression.evaluate(resource);
        LOG.debug(method, 'Evaluated JSONata expression', result);

        // Did we hit any relationships?
        if (accessedRelationships.length === 0) {

            // The result is safe to use.
            LOG.debug(method, 'No relationships were accessed');
            LOG.exit(method, result);
            return Promise.resolve(result);

        }

        // The result will be a promise chain of resolves and a retry.
        result = Promise.resolve();

        // Resolve all accessed relationships.
        while (accessedRelationships.length !== 0) {
            let accessedRelationship = accessedRelationships.shift();
            result = result.then(() => {
                LOG.debug(method, 'Resolving accessed relationship', accessedRelationship.relationship.toString());
                return this.resolver.resolveRelationship(accessedRelationship.relationship, {
                    cachedResources: new Map(),
                    skipRecursion: true
                })
                .then((resolvedResource) => {
                    LOG.debug(method, 'Resolved accessed relationship', accessedRelationship.relationship.toString());
                    this.prepareResource(resolvedResource, accessedRelationships);
                    accessedRelationship.resolve(resolvedResource);
                });
            });
        }

        // Try again.
        return result.then(() => {
            LOG.debug(method, 'Relationships were accessed and resolved, trying again');
            LOG.exit(method);
            return this.queryInternal(compiledExpression, resource, accessedRelationships, cachedResources);
        });

    }

    /**
     * Find all of the relationships in the specified resource.
     * @private
     * @param {Resource} resource The resource to resolve.
     * @return {FoundRelationship[]} The relationships in the specified resource.
     */
    findRelationships(resource) {
        const method = 'findRelationships';
        LOG.entry(method, resource.toString());
        let classDeclaration = resource.getClassDeclaration();
        let result = [];
        classDeclaration.getProperties().forEach((property) => {
            LOG.debug(method, 'Looking at property', property.getName());
            let value = resource[property.getName()];
            if (value instanceof Resource) {
                LOG.debug(method, 'Found resource property, recursing');
                result.concat(this.findRelationships(value));
            } else if (value instanceof Relationship) {
                LOG.debug(method, 'Found relationship property');
                result.push({
                    relationship: value,
                    resolve: (newValue) => { resource[property.getName()] = newValue; }
                });
            } else if (Array.isArray(value)) {
                LOG.debug(method, 'Found array property, iterating');
                value.forEach((item, index) => {
                    if (item instanceof Resource) {
                        LOG.debug(method, 'Found array resource property, recursing');
                        result.concat(this.findRelationships(item));
                    } else if (item instanceof Relationship) {
                        LOG.debug(method, 'Found array relationship property');
                        result.push({
                            relationship: item,
                            resolve: (newValue) => { resource[property.getName()][index] = newValue; }
                        });
                    } else {
                        LOG.debug(method, 'Found array primitive value, ignoring');
                    }
                });
            } else {
                LOG.debug(method, 'Found primitive value, ignoring');
            }
        });
        LOG.exit(method, result);
        return result;
    }

    /**
     * Modify the specified relationship so that for every property on the target
     * resource, a "special" property is added to the relationship with the same
     * name, which when accessed calls the specified callback.
     * @private
     * @param {Relationship} relationship The relationship to modify.
     * @param {function} callback The function to call when the "special" properties
     * are accessed.
     */
    modifyRelationship(relationship, callback) {
        const method = 'modifyRelationship';
        LOG.entry(method, relationship.toString());
        LOG.debug(method, 'Adding hidden $resolved property');
        Object.defineProperty(relationship, '$resolved', {
            enumerable: false,
            configurable: false,
            value: false,
            writable: true
        });
        let classDeclaration = relationship.getClassDeclaration();
        classDeclaration.getProperties().forEach((property) => {
            LOG.debug(method, 'Adding special property', property.getName());
            Object.defineProperty(relationship, property.getName(), {
                enumerable: true,
                configurable: false,
                get: () => {
                    if (!relationship.$resolved) {
                        callback();
                        relationship.$resolved = true;
                    }
                    return undefined;
                }
            });
        });
        LOG.exit(method);
    }

    /**
     * Prepare the specified resource so that every relationship in the resource
     * is modified so that if the relationship is accessed, it adds itself to the
     * list of accessed relationships.
     * @private
     * @param {Resource} resource The resource to prepare.
     * @param {FoundRelationship[]} accessedRelationships The working array of accessed relationships.
     */
    prepareResource(resource, accessedRelationships) {
        const method = 'prepareResource';
        LOG.entry(method, resource.toString(), accessedRelationships);

        // Don't prepare an already prepared resource.
        if (resource.hasOwnProperty('$prepared')) {
            LOG.exit(method);
            return;
        }
        Object.defineProperty(resource, '$prepared', {
            enumerable: false,
            configurable: false,
            value: true,
            writable: true
        });

        // Find all the relationships in this resource.
        const foundRelationships = this.findRelationships(resource);

        // Add properties to each relationship.
        foundRelationships.forEach((foundRelationship) => {
            LOG.debug(method, 'Found relationship object', foundRelationship.relationship.toString());
            this.modifyRelationship(foundRelationship.relationship, () => {
                accessedRelationships.push(foundRelationship);
            });
        });

        LOG.exit(method);
    }

}

module.exports = QueryExecutor;