Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit choices/enum field in model #759

Open
samul-1 opened this issue Nov 20, 2021 · 2 comments
Open

Limit choices/enum field in model #759

samul-1 opened this issue Nov 20, 2021 · 2 comments
Labels
enhancement New feature or request

Comments

@samul-1
Copy link

samul-1 commented Nov 20, 2021

Let's say I have a model that has a field state. This is represented as a number, but there is a finite number of states the object could be in. I'd like to be able to somehow assert that the value passed to this field (for example when creating a new instance of the entity) belongs to that set of possible choices. Since I'm using typescript in my project and I have already defined an enum that represents the possible states, I believe the best way would be to somehow enforce that the given value belong to that enum type.

Is there a way to accomplish this at the field definition level? Or can this be done somewhere else? Can it be done at all?

@cuebit
Copy link
Member

cuebit commented Nov 21, 2021

A very interesting point you make! An enum type would be desirable.

To answer your question, it isn't currently possible. The most convenient workaround is to intersect the data before it is passed to Vuex ORM, specifically for that key who's value needs to be "validated".

Alternatively, there's nothing stopping you from creating your own attribute in the form of a plugin and using it as you see fit.

That aside, if you feel this feature would be worth considered, create an issue in the vuex-orm/vuex-orm-next repo.

@cuebit cuebit added the enhancement New feature or request label Nov 21, 2021
@semiaddict
Copy link

semiaddict commented Dec 8, 2021

Hello @samul-1,
I also needed special validation, and opted to use JSON Schema (using Ajv) in combination with vuex-orm.
I am hoping to be able to make this available somehow (maybe as a plugin?) for the 1.0 version once it gets released.

Below is a simplified version of what I have so far with v0.36.4.
Note that it hasn't been fully tested yet, and only supports hasManyBy relationships for now.

utils/JSONSchema.js

import Ajv from "ajv";
import { clone, merge } from "lodash";

/**
 * Retreive properties from a JSON schema.
 *
 * @param {object} schema The schema
 * @param {Ajv?} validator An Ajv instance
 * @returns {object} The list of properties
 */
export const getProperties = (schema, validator = null) => {
  const root = !validator;
  if (root) {
    validator = new Ajv();
    validator.addSchema(schema);
  }
  if (schema.definitions) {
    Object.entries(schema.definitions).forEach(([k, d]) => {
      validator.addSchema(d, `#/definitions/${k}`);
    });
  }
  if (schema.$ref) {
    schema = validator.getSchema(schema.$ref).schema;
  }
  if (schema.properties) {
    return clone(schema.properties);
  }
  let res = {};
  const defs = schema["anyOf"] || schema["allOf"] || (root && schema["oneOf"]);
  if (defs) {
    defs.forEach((def) => {
      res = merge(res, getProperties(def, validator));
    });
  }
  return res;
};

AbstractModel.js

import Ajv from "ajv";
import { Model } from "@vuex-orm/core";
import { omitBy } from "lodash";
import { getProperties } from "./utils/JSONSchema";

/**
 * An abstract model based on {@link https://vuex-orm.org/|@vuex-orm} and {@link http://json-schema.org/|JSON schema}
 * Heavily inspired by {@link https://github.com/chialab/schema-model|schema-model},
 * and uses {@link https://ajv.js.org/|AJV} for shcema validation.
 *
 * @abstract
 */
export default class AbstractModel extends Model {
  /**
   * Get the ajv instance.
   *
   * @returns {Ajv} The Ajv instance
   */
  static get ajv() {
    if (!this._ajv) {
      this._ajv = new Ajv();
      this._ajv.addFormat("collection", { validate: () => true });
    }

    return this._ajv;
  }

  static get schema() {
    return {
      type: "object",
      properties: {},
      additionalProperties: false,
    };
  }

  /**
   * Get all properties from the schema
   * with all definitions merged.
   *
   * @returns {object} The list of properties in JSON schema format
   */
  static get properties() {
    return getProperties(this.schema);
  }

  /**
   * Get the list of required properties from the schema.
   *
   * @returns {string[]} The list of required properties
   */
  static get requiredProperties() {
    return this.schema.required;
  }

  /**
   * @inheritdoc
   */
  static fields() {
    const fields = {};

    for (const [key, schema] of Object.entries(this.properties)) {
      switch (schema.type) {
        case "array":
          if (schema.format === "collection") {
            const model = this.store().$db().model(schema.model);
            const foreign_key = schema.foreign_key;
            fields[key] = this.hasManyBy(model, foreign_key);
            fields[foreign_key] = this.attr([]);
          } else {
            fields[key] = this.attr(schema.default);
          }
          break;

        case "boolean":
          fields[key] = this.boolean(schema.default);
          break;

        case "string":
          if (schema.format === "uuid") {
            fields[key] = this.uid(schema.default);
          } else {
            fields[key] = this.string(schema.default);
          }
          break;

        default:
          fields[key] = this.attr(schema.default);
      }

      if (fields[key].nullable && !this.requiredProperties.includes(key)) {
        fields[key].nullable();
      }
    }

    return fields;
  }

  static beforeCreate(model) {
    if (!model.validate()) {
      console.error(model.errors);
      return false;
    }
  }

  static beforeUpdate(model) {
    if (!model.validate()) {
      console.error(model.errors);
      return false;
    }
  }

  /**
   * Alias to the static method of the same name
   *
   * @returns {object} The list of properties in JSON schema format
   */
  get properties() {
    return this.constructor.properties;
  }

  /**
   * Get the schema validator.
   *
   * @returns {Function} A validation function returned by Ajv
   */
  get validator() {
    if (!this._validator) {
      this._validator = this.constructor.ajv.compile(this.constructor.schema);
    }

    return this._validator;
  }

  get errors() {
    return this.validator.errors;
  }

  $toJson() {
    const internal_fields = [];
    for (const schema of Object.values(this.properties)) {
      if (
        schema.type === "array" &&
        schema.format === "collection" &&
        "foreign_key" in schema
      ) {
        internal_fields.push(schema.foreign_key);
      }
    }

    return omitBy(super.$toJson(), (value, key) => {
      return value === null || internal_fields.includes(key);
    });
  }

  /**
   * Validate data.
   *
   * @param {object} data The data to validate
   * @returns {boolean} True if the data is valid, false otherwise
   */
  validate() {
    return this.validator(this.$toJson());
  }
}

Example usage:
MyModel.js

import AbstractModel from "./AbstractModel";
import { merge } from "lodash";
import validateColor from "validate-color";

export class MyModel extends AbstractModel {
  static entity = "MyModel";

  static get schema() {
    const ajv = this.ajv;

    ajv.addFormat("color", { validate: validateColor });

    return merge(super.schema, {
      properties: {
        "id": {
            "type": "string",
            "title": "ID",
            "default": "",
        },
        "type": {
            "type": "string",
            "title": "Type",
            "default": ""
        },
        "color": {
            "type": "string",
            "format": "color",
            "title": "Color",
            "default": "#000",
        },
        "related": {
            "type": "array",
            "title": "Related",
            "default": [],
            "format": "collection",
            "foreign_key": "related_ids",
            "model": "AnotherModel"
          }
      },
      required: ["type", "id"],
    });
  }
}

export default MyModel;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants