JSON Model-to-Model Projection (JM2MP) Documentation

JSON Model-to-Model Projection (JM2MP) Documentation

source

adapters/jsonata.js

The module [JSONata]{@link module:jm2mp/adapters/jsonata} implements the [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter} interface to use the external [JMESPath]{@link external:JMESPath} query language as part of JM2MP projection documents.

Author

  • Luis Maria CAMARA ROSSI

Copyright

  • Universidad Nacional de Educación a Distancia (U.N.E.D.) 2026

License

  • BSD-3-Clause
/**
 * @author Luis Maria CAMARA ROSSI
 * @copyright Universidad Nacional de Educación a Distancia (U.N.E.D.) 2026
 * @license BSD-3-Clause
 * @file
 * The module [JSONata]{@link module:jm2mp/adapters/jsonata} implements
 * the [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
 * interface to use the external [JMESPath]{@link external:JMESPath}
 * _query language_ as part of `JM2MP` _projection documents_.
**/

/**
 * @module jm2mp/adapters/jsonata
 * @description
 * This module implements the
 * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
 * interface to use the [JSONata](https://jsonata.org/) _query language_
 * as part of `JM2MP` _projection documents_.
 *
 * This module _only_ supports **JSONata 2.x** _versions_. Older version
 * 1.x uses another incompatible API, as well as possibly future
 * versions (3.x).
 *
 * By design, **JSONata 2.x** is **async only**, as well as
 * [QueryAdapter.evaluate]{@link module:jm2mp/adapters/registry.QueryAdapter.evaluate} interface contract.
 *
 * By compliance with `JM2MP`, the
 * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
 * created by [createJsonataAdapter]{@link module:jm2mp/adapters/jsonata.createJsonataAdapter}
 * maintains the expected behaviour:
 * - `undefined` --> `null`.
 * - Type errors --> [EvaluationError]{@link module:jm2mp/errors.EvaluationError} with `cause`.
 * - `null` input --> `null` output without calling [JSONata]{@link external:JSONata} external library.
 * - Invalid expression during validation --> [ValidationError]{@link module:jm2mp/errors.ValidationError}.
 * - Invalid expression during runtime --> [EvaluationError]{@link module:jm2mp/errors.EvaluationError}.
 *
 * **Timeout mechanism**: this adapter supports an optional `timeout`
 * parameter (expressed in milliseconds). If the evaluation of an
 * expression takes too long, it will be rejected and a
 * [EvaluationError]{@link module:jm2mp/errors.EvaluationError} exception
 * will be raised, referencing such _timeout_. But the actual evaluation
 * will continue to run in the background until it naturally completes,
 * although its result will be discarded.
 * 
 * This _timeout mechanism_ is acceptable for well written expressions
 * but maybe insufficient for potentially malicious queries.
 *
 * The [jsonata](https://www.npmjs.com/package/jsonata)
 * library is dynamically loaded when constructing its corresponding
 * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}.
 * If not previously installed, an
 * [AdapterError]{@link module:jm2mp/errors.AdapterError} exception will
 * be raised from
 * [createJsonataAdapter]{@link module:jm2mp/adapters/jsonpath.createJsonataAdapter}
 * with a clear message about it.
 *
 * @see [JSONata (external)]{@link external:JSONata}
**/

/**
 * @external JSONata
 * @description
 * **jsonata** is a JavaScript implementation of **JSONata**, which is a
 * JSON query and transformation language. This package is the reference
 * implementation of the JSONata query and transformation language.
 *
 * The module {@link module:jm2mp/adapters/jsonata} implements the
 * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
 * interface to use **JSONata** as part of `JM2MP`
 * _projection documents_.
 *
 * @see {@link http://jsonata.org/}
 * @see {@link https://www.npmjs.com/package/jsonata}
 * @see {@link https://github.com/jsonata-js/jsonata}
**/

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */

import { AdapterError, ParseError, ValidationError, EvaluationError } from "../errors.js";

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */

/**
 * @description
 * It creates a new
 * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
 * dynamically loading
 * [JSONata](https://www.npmjs.com/package/jsonata) version **2.x**.
 * 
 * @param {object} [options]
 * @param {number} [options.timeout]
 * Timeout (expressed in _milliseconds_) for evaluating each query.
 * If its value is greater than zero, any evaluation that exceeds this
 * _timeout_ will raise an [EvaluationError]{@link module:jm2mp/errors.EvaluationError}
 * exception. If it is not defined, is zero or is less than zero, no
 * _timeout_ will be applied.
 * @returns {Promise<module:jm2mp/adapters/registry.QueryAdapter>}
 */
export async function createJsonataAdapter(options = {})
{
  // Trying to load JSONata external library.
  let jsonata;
  try {
    const mod = await import("jsonata");
    jsonata = mod.default ?? mod.jsonata ?? mod;
    if (typeof jsonata !== "function") {
      throw new Error("Function 'jsonata' (default or named export) not found!");
    }
  } catch (cause) {
    throw new AdapterError(
      "Unable to load 'jsonata 2.x.x' external library. " +
      "To install it, please use: `npm install jsonata@2` .",
      { cause }
    );
  }

  // Configuring timeout (<=0 --> no timeout).
  const timeout = (typeof options.timeout === "number" && options.timeout > 0)
    ? options.timeout
    : 0;

  /**
   * @description
   * Compila o recupera de caché una expresión JSONata.
   * Devuelve el objeto JSONata compilado (con .evaluate()).
   * @param {*} expr The expression literal to be compiled.
   * @param {*} cache The expression cache to save compilation's result.
   * @returns {*} The equivalent compiled JSONata expression.
   */
  function compileWithCache(expr, cache) {
    if (cache.has(expr)) return cache.get(expr);
    let compiled;
    try {
      compiled = jsonata(expr);
    } catch (cause) {
      throw new ParseError(
        `Expresión JSONata inválida: "${expr}".`,
        { cause }
      );
    }
    cache.set(expr, compiled);
    return compiled;
  }

  /**
   * @constant
   * @type {module:jm2mp/adapters/registry.QueryAdapter}
   * @description
   * The newly created
   * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
   * for the [JSONata]{@link external:JSONata} query language.
  **/
  const new_jsonata_query_adapter = {
    name: "jsonata",
    description: "JSONata 2.x query adapter.",

    /**
     * @description
     * It validates a JSONata expression, trying to compile it.
     */
    async validate(path) {
      if (typeof path !== "string" || path.length === 0) {
        throw new ValidationError(
          `JSONata: $path must be a non-empty string, instead of '${typeof path}'.`
        );
      }
      try {
        jsonata(path);
      } catch (cause) {
        throw new ValidationError(
          `Invalid JSONata expression: "${path}".`,
          { cause }
        );
      }
    },

    /**
     * @description
     * It evaluates a JSONata expression and normalize its result:
     * - It will return `null` whenever `input` is `null` or `undefined`.
     * - It will use a pre-compiled expression cache (specially useful
     * in loops).
     * - It _timeout_ (>0) is configured, then it applies `Promise.race`
     *   with `setTimeout`; when evaluation exceeds such timeout, an
     *   [EvaluationError]{@link module:jm2mp/errors.EvaluationError}
     *   exception will be raised (but actual JSONata expressions
     *   evaluation will be running in background until finished; only
     *   result is fast discarded).
     *
     * The `env` parameter is ignored: `JSONPath` does not support
     * lexical _aliases_ and does not distinguish between roots and
     * contexts, other than just the input; so the expression is always
     * evaluated against `input`.
    **/
    /* eslint-disable-next-line no-unused-vars -- _env */
    async evaluate(path, input, cache, _env) {
      // NULL absorptive propagation.
      if (input === null || input === undefined) return null;
      // Compiling using cache.
      let compiled;
      try {
        compiled = compileWithCache(path, cache);
      } catch (cause) {
        throw new EvaluationError(
          `Error during JSONata expressions compilation for "${path}".`,
          { cause }
        );
      }

      /**
       * @description
       * Function to normalize the NULL absorptive propagation:
       * undefined --> null.
       * @returns {*}
      **/
      const evalFn = async () => {
        let result;
        try {
          result = await compiled.evaluate(input);
        } catch (cause) {
          throw new EvaluationError(
            `Error evaluating JSONata expression "${path}".`,
            { cause }
          );
        }
        return (result === undefined ? null : result);
      };

      // When no timeout is configured, just evaluate.
      if (timeout === 0) {
        return await evalFn();
      }
      // If timeout is configured, then wait/race between evaluation and timeout.
      let timeoutHandle;
      const timeoutPromise = new Promise((_, reject) => {
        timeoutHandle = setTimeout(() => {
          reject(new EvaluationError(
            `JSONata evaluation of "${path}" exceeds the timeout of '${timeout}'ms.`
          ));
        }, timeout);
      });

      try {
        return await Promise.race([evalFn(), timeoutPromise]);
      } finally {
        // In Node.JS a setTimeout always must be ended with clearTimeout.
        if (timeoutHandle) clearTimeout(timeoutHandle);
      }
    },  // async inner function evaluate()

    /**
     * @property {@link module:jm2mp/adapters/registry.FallbackPolicyObject}
     * @description
     * [QueryAdapter]{@link module:jm2mp/adapters/registry.QueryAdapter}
     * behavior policy for `JSONata`.
    **/
    fallbackPolicy: {
      missing:
        "null (when JSONata returns undefined)",
      multipleMatches:
        "array (when JSONata expression returns a sequence)",
      singleMatch:
        "scalar (as-is)",
      typeError:
        "EvaluationError (wrapping errors)",
      nullInput:
        "null (without invoking external library)",
      timeout:
        ( ( timeout > 0 )
          ? `${timeout} (yes, in milliseconds; raising EvaluationError but continue running in background)`
          : "0 (no)" ),
    },
  };

  return new_jsonata_query_adapter;

/* ------------------------------------------------------------------ */

}  // export async function createJsonataAdapter

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* End of file: ${JM2MP.JS}/src/adapters/jsonata.js                   */