schema/basic-js-schema.js

/**
 * <p>
 * The schema part of using models for data.
 * </p>
 *
 * @namespace schema
 */
import { MissingRecordNameBinding } from "../errors.js";
import { conforms } from "./conforms.js";
import { fromSchemaToData } from "../models/models.js";
import { createDiff, applyDiff, makeChangeHandler } from "../diff/diff.js";
import { inflate } from "../models/utils.js";

/**
 * Get the string identifier for this schema-conformant data object.
 * @name schema.getRecordNameFor
 * @function
 * @param {*} schema - A schema definition
 * @param {*} instance - A schema-conformant data object
 * @returns {String} the string identifier for this schema-conformant data object.
 */
export function getRecordNameFor(schema, instance) {
  const indicator = schema.__meta.recordName;

  if (!indicator) {
    throw new MissingRecordNameBinding(schema.__proto__.constructor.name);
  }

  // If the recordName is a function, it will yield the record name given an instance.
  if (typeof indicator === `function`) {
    return indicator(instance);
  }

  // Otherwise, it's a keypath. Traverse the instance to find the key whose value should act as record name.
  return indicator.split(`.`).reduce((obj, e) => obj[e], instance);
}

/**
 * Fully quality a schema by linking in all external schema it depends on.
 * @name schema.linkSchema
 * @function
 * @param {*} schemaInstance - A schema instance with sub-schema link information.
 * @param {*} getLatestSchema - A resolver function for finding sub-schema definitions.
 * @returns {schema} The schema instance with all links replaced by fully qualified sub-schema.
 */
export function linkSchema(schemaInstance, getLatestSchema) {
  const { __meta } = schemaInstance;

  // is this a schema'd property?
  if (__meta && __meta.schema && __meta.schemaName) {
    const subschema = getLatestSchema(__meta.schemaName);
    schemaInstance.__meta = subschema.__meta;
    delete subschema.__meta;
    schemaInstance.shape = subschema;
    return;
  }

  // find all fields that we need to recurse through.
  Object.entries(schemaInstance).forEach(([key, value]) => {
    if (key === `__meta`) return;
    const recursionData = value.shape
      ? value.shape
      : value.__meta
      ? value
      : undefined;
    if (recursionData) linkSchema(recursionData, getLatestSchema);
  });
}

/**
 * Decompose a single schema into a set of linked schema, based
 * on the __meta.distinct property of modelfields with a .shape
 *
 * @name schema.unlinkSchema
 * @function
 * @param {*} schema - A fully qualified schema
 * @returns {schema[]} An array consisting of all individual schema found by walking through the passed schema.
 */
export function unlinkSchema(schema) {
  const list = [{ schema, __meta: schema.__meta }];

  (function iterate(s) {
    Object.entries(s).forEach(([key, value]) => {
      const { __meta, shape } = value;
      if (shape) {
        if (__meta?.distinct) {
          list.push({ schema: shape, __meta });
          s[key] = {
            __meta: {
              schema: shape.__proto__.constructor.name,
              schemaName: __meta.name,
            },
          };
        } else {
          iterate(shape);
        }
      }
    });
  })(schema);

  return list;
}

/**
 * Similar to unlinkSchema, without rewriting the parent
 * when a child model is found. Instead, we get a list of
 * all distinct Model classes.
 *
 * @name schema.getModelSet
 * @function
 * @param {Model} Model - A fully qualified {@link Model}.
 * @returns {Model[]} - An array consisting of all individual models found by walking through the passed model.
 * @see schema.unlinkSchema
 */
export function getModelSet(Model) {
  const list = [Model];

  (function iterate(model) {
    const s = new model(getModelSet, Date.now());
    Object.entries(s).forEach(([key, value]) => {
      const { __meta, shape } = value;
      if (shape) {
        //if (__meta?.distinct) {
        list.push(shape.__proto__.constructor);
        //} else {
        iterate(shape.__proto__.constructor);
        //}
      }
    });
  })(Model);

  return list;
}

/**
 * <p>Validate an object against a schema. This function does not return
 * a boolean, but a result object that takes the following form:</p>
 *
 * <pre><code>
 *   {
 *     passed: boolean,
 *     warnings: string[],
 *     errors: string[],
 *   }
 * </code></pre>
 *
 * <p>As such, use this function in your own code as:</p>
 *
 * <pre><code>
 *   const strict = trueOrFalse;
 *   const allowIncomplete = trueOrFalse;
 *   const result = validate(someSchema, myObject, strict, allowIncomplete);
 *   if (result.passed) {
 *     // all good, although result.warnings may contain strict/incomplete related warnings
 *   } else {
 *     // tap into result.errors to find out why your object is invalid
 *   }
 * </code></pre>
 *
 * <p>Note that the <code>strict</code> flag determines whether or not
 * to perform coercive validation. If <code>strict=false</code> and the
 * code does need to perform coercion in order for validation to pass,
 * the object will be updated such that it will now pass strict validation.</p>
 *
 * @name schema.validate
 * @function
 * @param {schema} schema - The schema that the object should conform to
 * @param {object} object - The object to check conformance for
 * @param {boolean} strict - True if strict type validation is required, false for coercive
 * @param {boolean} allowIncomplete - Do not fail validation if there are fields missing from this object
 * @returns {object} See description above
 */
export function validate(
  schema,
  object,
  strict = true,
  allowIncomplete = false
) {
  inflate(object);
  const results = conforms(schema, object, strict, allowIncomplete);
  results.passed = !!results.conforms;
  return results;
}

/**
 *
 */
/**
 * A convenience function for creating a validator that can be passed
 * around, bound to objects, etc.
 *
 * <pre><code>
 *   import { schema } from "use-models-for-data";
 *
 *   Object.defineProperty(mySchema, `validate`, {
 *     enumerable: false,
 *     value: schema.createValidator(mySchema, false)
 *   });
 * </code></pre>
 *
 * @name schema.createValidator
 * @function
 * @param {schema} schema - The schema against which to validate data objects.
 * @param {boolean} [strict] - True to perform strict validation, or false to validate with coercion. Defaults to <code>true</code>.
 * @param {boolean} [allowIncomplete] - True to allow missing required fields. Defaults to <code>false</code>.
 * @returns {function} a function with the same signature as {@link schema.validate}.
 */
export function createValidator(
  schema,
  strict = true,
  allowIncomplete = false
) {
  return (object) => validate(schema, object, strict, allowIncomplete);
}

/**
 * Create a schema instance with all values initialised to their default values.
 *
 * @name schema.createDefault
 * @function
 * @param {*} schema - The schema to instantiate
 * @returns {object} A schema-conformant object with all properties set to their default values.
 */
export function createDefault(schema) {
  return Object.fromEntries(
    Object.entries(schema)
      .map(([key, value]) => {
        if (key === `__meta`) return false;
        if (value.default !== undefined) return [key, value.default];
        if (value.shape) return [key, createDefault(value.shape)];
      })
      .filter(Boolean)
  );
}

/**
 * Migrate an object from being schema1-conformant to schema2-conformant.
 * @name schema.migrate
 * @function
 * @param {object} object - The object to migrate
 * @param {operations} operations - A sequence of operational transforms (e.g. a "diff").
 * @returns An object that conforms to the updated schema.
 */
/**
 * @name schema.migrate^2
 * @function
 * @param {object} object - The object to migrate
 * @param {schema} schema1 - The "original" schema from which to migrate our object.
 * @param {schema} schema2 - The "current" schema to which we want to migrate our object.
 * @returns An object that conforms to the updated schema.
 */
export function migrate(object, ...args) {
  const len = args.length;
  if (len === 1) {
    const [operations] = args;
    return migrateDirectly(object, operations);
  }
  if (len === 2) {
    const [schema1, schema2] = args;
    return migrateWithSchema(object, schema1, schema2);
  }
}

// fall-through for migrate(object, diff)
function migrateDirectly(object, changeOperations) {
  const changeHandler = makeSchemaChangeHandler();
  applyDiff(changeOperations, object, changeHandler);
}

// fall-through for migrate(object, schema1, schema2)
function migrateWithSchema(object, schema1, schema2) {
  const operations = createDiff(schema1, schema2);
  return migrateDirectly(object, operations);
}

/**
 * This generates a change handler to be used with {@link diff.applySchema}
 * in order to apply diffs between two schema definitions to objects that
 * should conform to those schema definitions.
 *
 * You should never need to invoke this function yourself.
 *
 * @name schema.makeSchemaChangeHandler
 * @function
 * @returns the schema-conformant object diff changeHandler.
 * @ignore
 */
export function makeSchemaChangeHandler() {
  return makeChangeHandler(ignoreKey, filterKeyString, transformValue);
}

/**
 * @name schema.ignoreKey
 * @function
 * @param {*} key - An operational-transform-associated key path
 * @param {*} type - operational transform type (add, update, move, remove)
 * @returns {boolean} True if this key/type tuple should be ignored for diff application purposes.
 * @ignore
 */
function ignoreKey(key, _type) {
  if (key.includes(`__meta`)) return true;
  if (key.includes(`.default`)) return true;
  if (key.includes(`.choices`)) return true;
}

/**
 * @name schema.filterKeyString
 * @function
 * @param {String} key - A key path string for an operational transform operation.
 * @returns {String} A (possibly different) key path string.
 * @ignore
 */
function filterKeyString(key) {
  return key.replaceAll(`.shape`, ``);
}

/**
 * @name schema.transformValue
 * @function
 * @param {String} key - A key path string for an operational transform operation.
 * @param {*} value - Any kind of JS data
 * @returns The key-path associated value after forcing "schema-to-data" conversion (which don't do anything if the value is already plain data)
 * @ignore
 */
function transformValue(key, value) {
  const copied = JSON.parse(JSON.stringify(value));
  const transformed = { [key]: copied };
  fromSchemaToData(transformed);
  return transformed[key];
}