models/fields.js

import {
  MissingChoicesArray,
  TypeNotMatchedToChoices,
  FieldFailedCustomValidation,
} from "../errors.js";
import { setDataFrom } from "./utils.js";
import * as basicSchema from "../schema/basic-js-schema.js";

/**
 * Generate correctly typed model fields, in the sense
 * that they are of a form that js-schema can work with.
 *
 * @ignore
 */
class ModelField {
  /**
   * Construct a model field object, with unpacked options,
   * mimicking a schema definition that matches what the
   * basic-js-schema code works with.
   *
   * @constructor
   * @param {Object} options - an options object.
   * @ignore
   */
  constructor(options = {}) {
    const defaultValue = options.default;
    const { type, choices, shape, ...rest } = options;
    delete rest.default;

    this.__meta = {};

    if (shape?.__meta) {
      this.__meta = shape.__meta;
      delete shape.__meta;
    }

    setDataFrom(rest, this.__meta);

    if (type !== undefined) this.type = type;
    if (defaultValue !== undefined) this.default = defaultValue;
    if (choices !== undefined) this.choices = choices;
    if (shape !== undefined) this.shape = shape;
  }
}

/**
 * <p>Static model field builder.</p>
 *
 * <p><strong>note</strong>: "one-or-more" is handled as `array: true`
 * in the options, rather than being its own type of model field. (On
 * the schema side this is the __meta.array:true property)</p>
 *
 * <p>All fields can be passed an options object that supports arbitrary
 * keywords, with some exceptions that are used by the schema system
 * itself:</p>
 *
 * <pre><code>
 *   {
 *     required: boolean,
 *     default: any value,
 *     choices: array of possible values,
 *     configurable: boolean,
 *     debug: boolean,
 *   }
 * </code></pre>
 *
 * @class
 * @hideconstructor
 */
export class Fields {
  /**
   * Model field definition for boolean values.
   *
   * @param {Object} options - an options object, see above.
   * @returns {ModelField}
   */
  static boolean(options = {}) {
    return new ModelField({ type: `boolean`, ...options });
  }

  /**
   *
   * @param {Object} options - an options object, see above.
   * @returns {ModelField}
   */
  static string(options = {}) {
    const type = `string`;
    testChoiceDefault(type, options);
    return new ModelField({ type, ...options });
  }

  /**
   *
   * @param {Object} options - an options object, see above.
   * @returns {ModelField}
   */
  static number(options = {}) {
    const type = `number`;
    testChoiceDefault(type, options);
    return new ModelField({ type, ...options });
  }

  /**
   *
   * @param {any[]} choices - Array of valid values for this field.
   * @param {Object} options - an options object, see above.
   * @returns {ModelField}
   */
  static choice(choices, options = {}) {
    if (choices === undefined || !(choices instanceof Array)) {
      throw new MissingChoicesArray();
    }
    return new ModelField({ type: undefined, choices, ...options });
  }

  /**
   *
   * @param {Model} model
   * @param {Object} options - an options object, see above.
   * @returns {ModelField}
   */
  static model(Model, options = {}) {
    return new ModelField({ shape: new Model(this, Date.now()), ...options });
  }
}

/**
 * @ignore
 * @param {*} type
 * @param {Object} options - an options object, see above.
 */
function testChoiceDefault(type, options) {
  const v = options.default;
  if (v !== undefined && options.choices) {
    if (typeof v !== type && !options.choices.includes(v)) {
      throw new TypeNotMatchedToChoices(type);
    }
  }
}

/**
 * Validate a model field
 *
 * @param {*} key
 * @param {*} value
 * @param {*} definition
 * @param {*} strict
 * @returns {boolean} True is this key/value pair passed validation, false if it didn't. Note that the value may have been rewritten to fit the correct type if <code>strict=false</code> was used.
 * @ignore
 */
export function validate(key, value, definition, strict = false) {
  const schema = {
    [key]: {
      __meta: definition.__meta,
      type: definition.type,
      default: definition.default,
    },
  };

  [`choices`].forEach((k) => {
    const v = definition[k];
    if (v !== undefined) schema[key][k] = v;
  });

  const customValidate = definition.__meta.validate;
  const basic = basicSchema.validate(schema, { [key]: value }, strict);
  if (!customValidate || !basic.passed) return basic;

  try {
    if (customValidate(value) === false) {
      throw new FieldFailedCustomValidation(key);
    }
    return { passed: true };
  } catch (err) {
    return { passed: false, errors: [err.message] };
  }
}