Source: processing.js

import {
  prettyJSON,
  isObject,
  isArray,
  isValue,
  normalizeValue,
  E100,
  E101,
  E102,
  E103,
  E104,
  deepCopy,
} from '../src';
import { xaType } from './types';

/**
 * ===========================================================================
 *     Pathify JSON function.
 * ===========================================================================
 */

/**
 * Takes a document and transforms nested objects into string paths.
 *
 * @param {Object} document A blob of incoming structured JSON data.
 * @returns {Object} Restructured version of the original document
 */
export function pathifyJSON(document) {
  return _pathifyJSON_Base(document, false);
}

function pathifyTableRow(document) {
  return _pathifyJSON_Base(document, true);
}

function _pathifyJSON_Base(document, table = false) {
  const newBlob = { values: {}, tables: {} };
  const path = [];

  if (typeof document !== 'object') {
    console.error(`Document is not valid:\n${document}`);
    return false;
  }

  _recurseAndAdd(null, path, document, newBlob);

  if (table === true && Object.keys(newBlob.tables).length > 0) throw 'Table rows include tables.';

  if (table === true) return newBlob.values;
  return newBlob;
}

/**
 * Modifies a restructured JSON object with all values stored with full JSON
 * string-paths in a values object, and all tables stored with full JSON
 * string-paths in a tables object. Used by pathifyJSON function.
 *
 * @private
 * @param {String} key JSON object key.
 * @param {Array} path Array of keys, the path to the current value.
 * @param {Object} value The JSON object value.
 * @param {Object} blob The returned object of the parent function
 */
function _recurseAndAdd(key, path, value, blob) {
  const valuePath = path.join('.');
  if (isObject(value)) {
    if (Object.keys(value).length === 0) return;
    Object.entries(value).forEach(([inner_key, inner_value]) => {
      const newPath = path.slice();
      newPath.push(inner_key);
      _recurseAndAdd(inner_key, newPath, inner_value, blob);
    });
  } else if (isArray(value)) {
    const tableObjects = [];
    const arrayValues = [];
    for (const arrayElementIndex in value) {
      const element = value[arrayElementIndex];
      if (isObject(element)) {
        tableObjects.push(pathifyTableRow(element));
      } else if (isValue(element)) {
        arrayValues.push(normalizeValue(element));
      }
    }
    if (tableObjects.length > 0) blob.tables[valuePath] = tableObjects;
    if (arrayValues.length > 0) blob.values[valuePath] = arrayValues;
  } else if (isValue(value)) {
    blob.values[valuePath] = normalizeValue(value);
  } else {
    console.error(`Unexpected object configuration found. Object:\n\n${prettyJSON(value)}`);
  }
}

/**
 * ===========================================================================
 *     Enforce Schema Function
 * ===========================================================================
 */

/**
 * Adds blank missing fields to a piece of content JSON, based on the fields present in the schema.
 * Throws an error if a field that is NOT in the schema is present in the content blob.
 *
 * Additionally, ignores schema enforcement.
 *
 * @param {JSON} schema A JSON blob containing the schema for the passed content
 * @param {JSON} content A JSON blob to be modified to fit the passed schema
 */
export function enforceSchemaNoCheck(schema, content) {
  return enforceSchema(schema, content, false, true);
}

/**
 * Adds blank missing fields to a piece of content JSON, based on the fields present in the schema.
 * Throws an error if a field that is NOT in the schema is present in the content blob.
 *
 * Additionally, adds an empty table row for any object-tables.
 *
 * @param {JSON} schema A JSON blob containing the schema for the passed content
 * @param {JSON} content A JSON blob to be modified to fit the passed schema
 */
export function enforceSchemaWithTables(schema, content) {
  return enforceSchema(schema, content, true, false);
}

/**
 * Adds blank missing fields to a piece of content JSON, based on the fields present in the schema.
 * Throws an error if a field that is NOT in the schema is present in the content blob.
 *
 * @param {JSON} schema A JSON blob containing the schema for the passed content
 * @param {JSON} content A JSON blob to be modified to fit the passed schema
 * @param {boolean} skipSchemaCheck For development: skip checkSchema tests for test simplicity.
 */
export function enforceSchema(schema, content, addEmptyTableRows = false, skipSchemaCheck = false) {
  if (!skipSchemaCheck) checkSchema(schema);

  // Take a deep copy so we don't alter the original content object.
  const newContent = deepCopy(content);

  // First, check for fields in the content that are NOT present in the schema:
  _enforceSchemaCheckForIncorrectFields(schema, newContent);

  // Then, check for fields in the schema that need to be added to the content.
  return _enforceSchemaAddNewFields(schema, newContent, addEmptyTableRows);
}

/**
 * Checks the content JSON for incorrect keys or keys with incorrect values.
 * @private
 * @param {JSON} schema The schema JSON to observe.
 * @param {JSON} content The conent JSON to check.
 */
function _enforceSchemaCheckForIncorrectFields(schema, content) {
  const schemaKeys = Object.keys(schema).filter(_isNotInfoKey);
  Object.keys(content).forEach((val) => {
    // If a key is present in the content that is not present in the schema, throw an error.
    if (!schemaKeys.includes(val)) throw E100 + ` -> ${val}`;

    // If a key is a different type than specified in the schema, throw an error.
    if (xaType(content[val]) !== xaType(schema[val]))
      throw (
        E101 +
        ` -> ${val} (${content[val]}) has type ${typeof content[val]} instead of ${typeof schema[
          val
        ]}`
      );

    // Recursive run for objects.
    if (isObject(content[val])) {
      _enforceSchemaCheckForIncorrectFields(schema[val], content[val]);
    }

    // Recursive runs for arrays of objects.
    if (isArray(content[val]) && content[val].every(isObject)) {
      content[val].forEach((obj) => {
        _enforceSchemaCheckForIncorrectFields(schema[val][0], obj);
      });
    }

    // Arrays can contain values or objects, not both.
    if (isArray(schema[val]) && schema[val].some(isObject) && schema[val].some(isValue)) throw E102;

    if (isArray(content[val]) && content[val].some(isObject) && content[val].some(isValue))
      throw E102 + ` -> ${val}`;
  });
}

/**
 * Adds blank missing fields to all objects in the schema.
 * @private
 * @param {JSON} schema The schema JSON to observe.
 * @param {JSON} content The conent JSON to modify.
 */
function _enforceSchemaAddNewFields(schema, content, addEmptyTableRows = false) {
  const schemaKeys = Object.keys(schema).filter(_isNotInfoKey);
  const contentKeys = Object.keys(content);
  schemaKeys.forEach((key) => {
    // Values
    if (isValue(schema[key])) {
      if (!contentKeys.includes(key)) {
        // Include empty value.
        if (typeof schema[key] === 'number') {
          content[key] = 0;
        } else {
          content[key] = '';
        }
      }
    }

    // Objects
    if (isObject(schema[key])) {
      if (!contentKeys.includes(key)) content[key] = {};
      content[key] = _enforceSchemaAddNewFields(schema[key], content[key], addEmptyTableRows);
    }

    // Arrays of Objects
    if (isArray(schema[key]) && schema[key].every(isObject) && schema[key].length === 1) {
      if (!contentKeys.includes(key)) {
        content[key] = [];
      } else {
        // Ensure every object in the table matches the schema's first entry.
        const contentArray = content[key];
        const arraySchema = schema[key][0];
        for (let i = 0; i < contentArray.length; i++) {
          content[key][i] = _enforceSchemaAddNewFields(
            arraySchema,
            contentArray[i],
            addEmptyTableRows
          );
        }
      }

      // If the table is empty, add a key
      if (addEmptyTableRows && content[key].length === 0) {
        // Populate the row with an object if addEmptyTableRows is true.
        content[key].push({});
        _enforceSchemaAddNewFields(schema[key][0], content[key][0], addEmptyTableRows);
      }
    } else if (
      // If the array is empty/full of values, add it.
      isArray(schema[key]) &&
      (schema[key].every(isValue) || schema[key].length === 0)
    ) {
      if (!contentKeys.includes(key)) content[key] = [];
    }
  });
  return content;
}

/**
 * ===========================================================================
 *     Check Schema Function
 * ===========================================================================
 */

/**
 * Adds blank missing fields to a piece of content JSON, based on the fields present in the schema.
 * Throws an error if a field that is NOT in the schema is present in the content blob.
 *
 * @param {JSON} schema A JSON blob containing a rule schema.
 */
export function checkSchema(schema) {
  const allSchemaKeys = Object.keys(schema);
  const schemaKeys = allSchemaKeys.filter(_isNotInfoKey);

  // Every key must have a corresponding infoKey:
  schemaKeys.forEach((key) => {
    const infoKey = `__${key}`;
    if (!allSchemaKeys.includes(infoKey)) throw E103 + `, missing InfoKey: ${infoKey}`;
    if (!isValue(schema[infoKey])) throw 'InfoKeys can only be values.';
    if (isObject(schema[key])) checkSchema(schema[key]);

    // Arrays can contain values or objects, not both.
    if (isArray(schema[key]) && schema[key].some(isObject) && schema[key].some(isValue)) throw E102;

    if (isArray(schema[key]) && schema[key].every(isObject) && schema[key].length > 1) throw E104;
  });
}

/**
 * ===========================================================================
 *     Schema Helper Functions
 * ===========================================================================
 */

/**
 * Returns true if key does not start with double underscore, indicating an infoKey
 * @private
 * @param {String} key
 */
function _isNotInfoKey(key) {
  return !key.startsWith('__');
}