kripod/knex-orm

View on GitHub
src/model-base.js

Summary

Maintainability
A
55 mins
Test Coverage
import Ajv from 'ajv';
import { tableize } from 'inflection';
import QueryBuilder from './query-builder';
import Relation from './relation';
import RelationType from './enums/relation-type';
import {
  DuplicateModelError,
  EmptyModelError,
  UnidentifiedModelError,
  ValidationError,
} from './errors';

/**
 * Base Model class which should be used as an extension for database entities.
 */
export default class ModelBase {
  /**
   * Knex client corresponding to the current ORM instance.
   * @type {Object}
   * @memberof ModelBase
   * @static
   */
  static knex;

  /**
   * Plugins to be used for the current ORM instance.
   * @type {Object[]}
   * @memberof ModelBase
   * @static
   */
  static plugins = [];

  static registry = [];

  /**
   * Case-sensitive name of the database table which corresponds to the Model.
   * @type {string}
   */
  static get tableName() { return tableize(this.name); }

  /**
   * Primary key of the Model, used for instance identification.
   * @type {string}
   */
  static get primaryKey() { return 'id'; }

  /**
   * List of properties which should exclusively be present in database
   * entities. If the list is empty, then every enumerable property of the
   * instance are considered to be database entities.
   * @type {string[]}
   */
  static get whitelistedProps() { return []; }

  /**
   * List of properties which shall not be present in database entities. The
   * blacklist takes precedence over any whitelist rule.
   * @type {string[]}
   */
  static get blacklistedProps() { return []; }

  /**
   * JSON Schema to be used for validating instances of the Model. Validation
   * happens automatically before executing queries.
   * @type{?Object}
   */
  static get jsonSchema() { return null; }

  /**
   * Registers this static Model object to the list of database objects.
   * @param {string} [name] Name under which the Model shall be registered.
   * @throws {DuplicateModelError}
   * @returns {Model} The current Model.
   */
  static register(name) {
    // Clone Knex and initialize plugins
    this.knex = Object.assign({}, this.knex);
    for (const plugin of this.plugins) {
      plugin.init(this);
    }

    // Determine the Model's name and then check if it's already registered
    const modelName = name || this.name;
    if (Object.keys(this.registry).indexOf(modelName) >= 0) {
      throw new DuplicateModelError(modelName);
    }

    this.registry[modelName] = this;
    return this;
  }

  /**
   * Returns a new QueryBuilder instance which corresponds to the current Model.
   * @returns {QueryBuilder}
   */
  static query() {
    return new QueryBuilder(this);
  }

  /**
   * Creates a one-to-one relation between the current Model and a target.
   * @param {string|Model} Target Name or static reference to the joinable
   * table's Model.
   * @param {string} [foreignKey] Foreign key in the target Model.
   * @returns {Relation}
   */
  static hasOne(Target, foreignKey) {
    return new Relation(this, Target, RelationType.ONE_TO_ONE, foreignKey);
  }

  /**
   * Creates a one-to-many relation between the current Model and a target.
   * @param {string|Model} Target Name or static reference to the joinable
   * table's Model.
   * @param {string} [foreignKey] Foreign key in the target Model.
   * @returns {Relation}
   */
  static hasMany(Target, foreignKey) {
    return new Relation(this, Target, RelationType.ONE_TO_MANY, foreignKey);
  }

  /**
   * Creates a many-to-one relation between the current Model and a target.
   * @param {string|Model} Target Name or static reference to the joinable
   * table's Model.
   * @param {string} [foreignKey] Foreign key in this Model.
   * @returns {Relation}
   */
  static belongsTo(Target, foreignKey) {
    return new Relation(this, Target, RelationType.MANY_TO_ONE, foreignKey);
  }

  /**
   * Creates a new Model instance.
   * @param {Object} [props={}] Initial properties of the instance.
   * @param {boolean} [isNew=true] True if the instance is not yet stored
   * persistently in the database.
   */
  constructor(props = {}, isNew = true) {
    // Set the initial properties of the instance
    Object.assign(this, props);

    // Initialize a store for old properties of the instance
    Object.defineProperties(this, {
      isNew: {
        value: isNew,
      },
      oldProps: {
        value: isNew ? {} : Object.assign({}, props),
        writable: true,
      },
    });
  }

  /**
   * Validates all the enumerable properties of the current instance.
   * @throws {ValidationError}
   */
  validate() {
    const schema = this.constructor.jsonSchema;
    if (!schema) return; // The Model is valid if no schema is given

    const ajv = new Ajv();
    if (!ajv.validate(schema, this)) {
      throw new ValidationError(ajv.errors);
    }
  }

  /**
   * Queues fetching the given related Models of the current instance.
   * @param {...string} props Relation attributes to be fetched.
   * @returns {QueryBuilder}
   */
  fetchRelated(...props) {
    const qb = this.getQueryBuilder();
    if (!qb) throw new UnidentifiedModelError();

    return qb.withRelated(...props);
  }

  /**
   * Queues the deletion of the current Model from the database.
   * @throws {UnidentifiedModelError}
   * @returns {QueryBuilder}
   */
  del() {
    const qb = this.getQueryBuilder();
    if (!qb) throw new UnidentifiedModelError();

    return qb.del();
  }

  /**
   * Queues saving (creating or updating) the current Model in the database.
   * @throws {EmptyModelError}
   * @returns {QueryBuilder}
   */
  save() {
    const qb = this.getQueryBuilder();
    const changedProps = {};

    // By default, save only the whitelisted properties, but if none is present,
    // then save every property. Use the blacklist for filtering the results.
    const savablePropNames = (
      this.constructor.whitelistedProps.length > 0 ?
      this.constructor.whitelistedProps :
      Object.keys(this)
    ).filter((propName) =>
      this.constructor.blacklistedProps.indexOf(propName) < 0
    );

    for (const propName of savablePropNames) {
      const oldValue = this.oldProps[propName];
      const newValue = this[propName];

      // New and modified properties must be updated
      if (oldValue === undefined || newValue !== oldValue) {
        changedProps[propName] = newValue;
      }
    }

    // Don't run unnecessary queries
    if (Object.keys(changedProps).length === 0) {
      if (!qb) throw new EmptyModelError();

      return qb;
    }

    // Update the Model's old properties with the new ones
    Object.assign(this.oldProps, changedProps);

    // Insert or update the current instance in the database
    return qb ?
      qb.update(changedProps) :
      this.constructor.query().insert(changedProps);
  }

  /**
   * @returns {?QueryBuilder}
   * @private
   */
  getQueryBuilder() {
    if (this.isNew) return null;

    return new QueryBuilder(this.constructor, this);
  }
}