infinum/mobx-collection-store

View on GitHub
src/Model.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  action, computed, extendObservable,
  IArrayChange, IArraySplice, IComputedValue,
  intercept, IObservableArray, IObservableObject,
  isObservableArray, observable, toJS,
} from 'mobx';

import patchType from './enums/patchType';
import ICollection from './interfaces/ICollection';
import IDictionary from './interfaces/IDictionary';
import IExternalRef from './interfaces/IExternalRef';
import IModel from './interfaces/IModel';
import IModelConstructor from './interfaces/IModelConstructor';
import IOpts from './interfaces/IOpts';
import IPatch from './interfaces/IPatch';
import IReferences from './interfaces/IReferences';
import IType from './interfaces/IType';

import {Collection} from './Collection';
import {DEFAULT_TYPE, RESERVED_KEYS, TYPE_PROP} from './consts';
import {assign, first, getProp, getType, mapItems, setProp} from './utils';

type IChange = IArraySplice<IModel> | IArrayChange<IModel>;

/**
 * MobX Collection Model class
 *
 * @class Model
 * @implements {IModel}
 */
export class Model implements IModel {

  /**
   * The attribute that should be used as the unique identifier
   *
   * @static
   * @type {string|Array<string>}
   * @memberOf Model
   */
  public static idAttribute: string|Array<string> = 'id';

  /**
   * The references that the model can have to other models
   *
   * @static
   * @type {IReferences}
   * @memberOf Model
   */
  public static refs: IReferences = {};

  /**
   * Default values of model props
   *
   * @static
   * @type {IDictionary}
   * @memberOf Model
   */
  public static defaults: IDictionary = {};

  /**
   * Type of the model
   *
   * @static
   * @type {IType}
   * @memberOf Model
   */
  public static type: IType = DEFAULT_TYPE;

  /**
   * Atribute name for the type attribute
   *
   * @static
   * @type {string|Array<string>}
   * @memberOf Model
   */
  public static typeAttribute: string|Array<string> = TYPE_PROP;

  /**
   * Defines if the model should use autoincrement id if none is defined
   *
   * @static
   * @type {boolean}
   * @memberOf Model
   */
  public static enableAutoId: boolean = true;

  /**
   * Autoincrement counter used for the builtin function
   *
   * @public
   * @static
   *
   * @memberOf Model
   */
  public static autoincrementValue = 1;

  /**
   * Function that can process the received data (e.g. from an API) before
   * it's transformed into a model
   *
   * @static
   * @param {object} [rawData={}] - Raw data
   * @returns {object} Transformed data
   *
   * @memberOf Model
   */
  public static preprocess(rawData: object = {}): object {
    return rawData;
  }

  /**
   * Function used for generating the autoincrement IDs
   *
   * @static
   * @returns {number|string} id
   *
   * @memberOf Model
   */
  public static autoIdFunction(): number|string {
    const id = this.autoincrementValue;
    this.autoincrementValue++;
    return id;
  }

  /**
   * Collection the model belongs to
   *
   * @type {ICollection}
   * @memberOf Model
   */
  public __collection?: ICollection = null;

  /**
   * List of properties that were initialized on the model
   *
   * @private
   * @type {Array<string>}
   * @memberOf Model
   */
  private __initializedProps: Array<string> = [];

  /**
   * The model references
   *
   * @static
   * @type {IReferences}
   * @memberOf Model
   */
  private __refs: {[key: string]: number|string} = {};

  /**
   * Internal data storage
   *
   * @private
   * @type {IObservableObject}
   * @memberOf Model
   */
  private __data: IObservableObject = observable({});

  /**
   * A list of all registered patch listeners
   *
   * @private
   * @memberof Model
   */
  private __patchListeners: Array<(change: IPatch, model: IModel) => void> = [];

  /**
   * Determines if the patch listeners should be called on change
   *
   * @private
   * @type {boolean}
   * @memberof Model
   */
  private __silent: boolean = true;

  /**
   * Creates an instance of Model.
   *
   * @param {Object} initialData
   * @param {Collection} [collection]
   *
   * @memberOf Model
   */
  constructor(initialData?: object, opts?: IOpts, collection?: Collection);
  constructor(initialData?: object, collection?: Collection);
  constructor(initialData: object = {}, opts?: IOpts|Collection, collection?: Collection) {
    const data = assign({}, this.static.defaults, this.static.preprocess(initialData));

    let collectionInstance = collection;
    const idAttribute = this.static.idAttribute;
    let idSet = false;

    if (opts instanceof Collection) {
      collectionInstance = opts;
    } else if (typeof opts === 'string' || typeof opts === 'number') {
      setProp(data, this.static.typeAttribute, opts);
    } else if (opts && typeof opts === 'object') {
      if (opts.type) {
        setProp(data, this.static.typeAttribute, opts.type);
      }
      if (opts.id || opts.id === 0) {
        setProp(data, idAttribute, opts.id);
        idSet = true;
      }
    }

    if (!idSet) {
      this.__ensureId(data, collectionInstance);
      setProp({}, idAttribute, getProp<string|number>(data, idAttribute));
    }

    // No need for it to be observable
    this.__collection = collectionInstance;

    this.__initRefGetters();
    this.update(data);
    this.__silent = false;
  }

  /**
   * Static model class
   *
   * @readonly
   * @type {typeof Model}
   * @memberOf Model
   */
  get static(): typeof Model {
    return this.constructor as typeof Model;
  }

  /**
   * Update the existing model
   *
   * @augments {IModel|object} data - The new model
   * @returns {object} Values that have been updated
   *
   * @memberOf Model
   */
  @action public update(data: IModel | object): object {
    if (data === this) {
      return this; // Nothing to do - don't update with itself
    }
    const vals = {};

    Object.keys(data).forEach(this.__updateKey.bind(this, vals, data));

    return vals;
  }

  /**
   * Set a specific model property
   *
   * @argument {string} key - Property to be set
   * @argument {T} value - Value to be set
   * @returns {T|IModel} The assigned value (Can be an IModel)
   *
   * @memberOf Model
   */
  @action public assign<T>(key: string, value: T): T|IModel|Array<IModel> {
    let val: T|IModel|Array<IModel> = value;
    const isRef: boolean = key in this.__refs;
    if (isRef) {
      val = this.__setRef(key, value);
    } else {
      const patchAction = key in this.__data ? patchType.REPLACE : patchType.ADD;
      const oldValue = this.__data[key];
      // TODO: Could be optimised based on __initializedProps?
      extendObservable(this.__data, {[key]: value});

      this.__triggerChange(patchAction, key, value, oldValue);
    }
    this.__ensureGetter(key);
    return val;
  }

  /**
   * Assign a new reference to the model
   *
   * @template T
   * @param {string} key - reference name
   * @param {T} value - reference value
   * @param {IType} [type] - reference type
   * @returns {(T|IModel|Array<IModel>)} - referenced model(s)
   *
   * @memberOf Model
   */
  @action public assignRef<T>(key: string, value: T, type?: IType): T|IModel|Array<IModel> {

    /* istanbul ignore next */
    if (typeof this.static.refs[key] === 'object') {
      throw new Error(key + ' is an external reference');
    }

    if (key in this.__refs) { // Is already a reference
      return this.assign<T>(key, value);
    }

    const item = value instanceof Array ? first(value) : value;
    this.__refs[key] = item instanceof Model ? getType(item) : type;
    const data = this.__setRef(key, value);
    this.__initRefGetter(key, this.__refs[key]);
    this.__triggerChange(patchType.ADD, key, data);
    return data;
  }

  /**
   * Unassign a property from the model
   *
   * @param {string} key A property to unassign
   * @memberof Model
   */
  @action public unassign(key: string): void {
    const oldValue = this.__data[key];
    delete this.__data[key];
    this.__triggerChange(patchType.REMOVE, key, undefined, oldValue);
  }

  /**
   * Convert the model into a plain JS Object in order to be serialized
   *
   * @returns {IDictionary} Plain JS Object representing the model
   *
   * @memberOf Model
   */
  public toJS(): IDictionary {
    const data: IDictionary = toJS(this.__data);
    data[TYPE_PROP] = getType(this);
    return data;
  }

  /**
   * Exposed snapshot state of the model
   *
   * @readonly
   * @memberof Model
   */
  @computed public get snapshot() {
    return this.toJS();
  }

  /**
   * Add a listener for patches
   *
   * @param {(data: IPatch) => void} listener A new listener
   * @returns {() => void} Function used to remove the listener
   * @memberof Model
   */
  public patchListen(listener: (data: IPatch, model: IModel) => void): () => void {
    this.__patchListeners.push(listener);

    return () => {
      this.__patchListeners = this.__patchListeners.filter((item) => item !== listener);
    };
  }

  /**
   * Apply an existing JSONPatch on the model
   *
   * @param {IPatch} patch The patch object
   * @memberof Model
   */
  public applyPatch(patch: IPatch): void {
    const field = patch.path.slice(1);

    /* istanbul ignore else */
    if (patch.op === patchType.ADD || patch.op === patchType.REPLACE) {
      this.assign(field, patch.value);
    } else if (patch.op === patchType.REMOVE) {
      this.unassign(field);
    }
  }

  public getRecordId(): number|string {
    return getProp<number|string>(this, this.static.idAttribute);
  }

  public getRecordType(): IType {
    return getProp<IType>(this, this.static.typeAttribute) || this.static.type;
  }

  /**
   * Ensure the new model has a valid id
   *
   * @private
   * @param {any} data - New model object
   * @param {any} [collection] - Collection the model will belong to
   *
   * @memberOf Model
   */
  private __ensureId(data: IDictionary, collection?: ICollection) {
    const idAttribute = this.static.idAttribute;
    const id = getProp<string|number>(data, idAttribute);
    if (!id) {
      if (!this.static.enableAutoId) {
        throw new Error(`${idAttribute} is required!`);
      } else {
        let newId;
        do {
          newId = this.static.autoIdFunction();
          const idProp = setProp(data, idAttribute, newId);
        } while (collection && collection.find(getType(this), newId));
      }
    }
  }

  /**
   * Add new reference getter/setter to the model
   *
   * @private
   * @param {any} ref - reference name
   *
   * @memberOf Model
   */
  private __initRefGetter(ref: string, type?: IType) {
    const staticRef = this.static.refs[ref];
    if (typeof staticRef === 'object') {
      extendObservable(this, {
        [ref]: this.__getExternalRef(staticRef),
      });
    } else {
      this.__initializedProps.push(ref, `${ref}Id`);
      this.__refs[ref] = type || staticRef;

      // Make sure the reference is observable, even if there is no default data
      if (!(ref in this.__data)) {
        extendObservable(this.__data, {[ref]: null});
      }

      extendObservable(this, {
        [ref]: this.__getRef(ref),
        [`${ref}Id`]: this.__getProp(ref),
      });
    }
  }

  /**
   * An calculated external reference getter
   *
   * @private
   * @param {IExternalRef} ref - Reference definition
   * @returns {(IComputedValue<IModel|Array<IModel>>)}
   *
   * @memberof Model
   */
  private __getExternalRef(ref: IExternalRef): IComputedValue<IModel|Array<IModel>> {
    return computed(() => {
      return !this.__collection ? [] : this.__collection.findAll(ref.model)
        .filter((model: IModel) => {
          const prop = model[ref.property];
          if (prop instanceof Array || isObservableArray(prop)) {
            return prop.indexOf(this) !== -1;
          } else {
            return prop === this;
          }
        });
    });
  }

  /**
   * Initialize the reference getters based on the static refs property
   *
   * @private
   *
   * @memberOf Model
   */
  private __initRefGetters(): void {
    const refKeys: Array<string> = Object.keys(this.static.refs);

    for (const ref of refKeys) {
      this.__initRefGetter(ref);
    }
  }

  /**
   * Getter for the computed referenced model
   *
   * @private
   * @argument {string} ref - Reference name
   * @returns {IComputedValue<IModel>} Getter function
   *
   * @memberOf Model
   */
  private __getRef(ref: string): IComputedValue<IModel|Array<IModel>> {
    return computed(
      () => this.__collection ? this.__getReferencedModels(ref) : null,
      (value) => this.assign(ref, value),
    );
  }

  /**
   * Getter for the computed property value
   *
   * @private
   * @argument {string} key - Property name
   * @returns {IComputedValue<IModel>} Getter function
   *
   * @memberOf Model
   */
  private __getProp(key: string): IComputedValue<IModel> {
    return computed(
      () => this.__data[key],
      (value) => this.assign(key, value),
    );
  }

  /**
   * Get the reference id
   *
   * @private
   * @template T
   * @param {IType} type - type of the reference
   * @param {T} item - model reference
   * @returns {number|string}
   *
   * @memberOf Model
   */
  private __getValueRefs(type: IType, item: IModel | object): number|string {

    /* istanbul ignore next */
    if (!item) { // Handle case when the ref is unsetted
      return null;
    }

    if (typeof item === 'object') {
      const model = this.__collection.add(item, type);
      if (getType(model) !== type) {
        throw new Error(`The model should be a '${type}'`);
      }
      return getProp<string|number>(model, model.static.idAttribute);
    }
    return item;
  }

  /**
   * Update the referenced array on push/pull/update
   *
   * @private
   * @param {IType} ref - reference name
   * @param {any} change - MobX change object
   * @returns {null} no direct change
   *
   * @memberOf Model
   */
  @action private __partialRefUpdate(ref: IType, change: IChange): IChange {
    const type = this.__refs[ref];

    /* istanbul ignore else */
    if (change.type === 'splice') {
      const added = change.added.map(this.__getValueRefs.bind(this, type));
      this.__data[ref].splice(change.index, change.removedCount, ...added);
      return null;
    } else if (change.type === 'update') {
      const newValue = this.__getValueRefs(type, change.newValue);
      this.__data[ref][change.index] = newValue;
      return null;
    }

    /* istanbul ignore next */
    return change;
  }

  /**
   * Get the model(s) referenced by a key
   *
   * @private
   * @param {string} key - the reference key
   * @returns {(IModel|Array<IModel>)}
   *
   * @memberOf Model
   */
  private __getReferencedModels(key: string): IModel|Array<IModel> {
    const dataModels = mapItems<IModel>(this.__data[key], (refId: string) => {
      return this.__collection.find(this.__refs[key], refId);
    });

    if (dataModels instanceof Array) {
      const data: IObservableArray<IModel> = observable(dataModels);
      intercept(data, (change: IChange) => this.__partialRefUpdate(key, change));
      return data;
    }

    return dataModels;
  }

  /**
   * Setter for the referenced model
   * If the value is an object it will be upserted into the collection
   *
   * @private
   * @argument {string} ref - Reference name
   * @argument {T} val - The referenced mode
   * @returns {IModel} Referenced model
   *
   * @memberOf Model
   */
  private __setRef<T>(ref: string, val: T|Array<T>): IModel|Array<IModel> {
    const isArray = val instanceof Array || isObservableArray(val);
    const hasModelInstances = isArray
      ? (val as Array<T>).some((item) => item instanceof Model)
      : val instanceof Model;

    if (!this.__collection && hasModelInstances) {
      throw new Error('Model needs to be in a collection to set a reference');
    }
    // @ts-ignore
    const data: object|Array<object>|null = val;
    const type = this.__refs[ref];
    const refs = mapItems<number|string>(data, this.__getValueRefs.bind(this, type));

    const getRef = () => this.__collection ? (this.__getReferencedModels(ref) || undefined) : undefined;

    const oldValue = getRef();
    const patchAction = oldValue === undefined ? patchType.ADD : patchType.REPLACE;

    // TODO: Could be optimised based on __initializedProps?
    extendObservable(this.__data, {[ref]: refs});

    const newValue = getRef();
    this.__triggerChange(newValue === undefined ? patchType.REMOVE : patchAction, ref, newValue, oldValue);

    // Handle the case when the ref is unsetted
    if (!refs) {
      return null;
    }

    // Find the referenced model(s) in collection
    return this.__collection ? this.__getReferencedModels(ref) : null;
  }

  /**
   * Update the model property
   *
   * @private
   * @param {any} vals - An object of all updates
   * @param {any} data - Data used to update
   * @param {any} key - Key to be updated
   * @returns
   *
   * @memberOf Model
   */
  private __updateKey(vals, data, key) {
    const idAttribute = this.static.idAttribute;
    if (RESERVED_KEYS.indexOf(key) !== -1) {
      return; // Skip the key because it would override the internal key
    }
    if (key !== idAttribute || !getProp<string|number>(this.__data, idAttribute)) {
      vals[key] = this.assign(key, data[key]);
    }
  }

  /**
   * Add getter if it doesn't exist yet
   *
   * @private
   * @param {string} key
   *
   * @memberOf Model
   */
  private __ensureGetter(key: string) {
    if (this.__initializedProps.indexOf(key) === -1) {
      this.__initializedProps.push(key);
      extendObservable(this, {[key]: this.__getProp(key)});
    }
  }

  /**
   * Function that creates a patch object and calls all listeners
   *
   * @private
   * @param {patchType} type Action type
   * @param {string} field Field where the action was made
   * @param {*} [value] The new value (if it applies)
   * @memberof Model
   */
  private __triggerChange(type: patchType, field: string, value?: any, oldValue?: any): void {
    if (this.__silent) {
      return;
    }

    if (type === patchType.REPLACE && value === oldValue) {
      return;
    }

    const patchObj: IPatch = {
      oldValue,
      op: type,
      path: `/${field}`,
      value,
    };

    this.__patchListeners.forEach((listener) => typeof listener === 'function' && listener(patchObj, this));

    if (this.__collection) {
      // tslint:disable-next-line:no-string-literal
      this.__collection['__onPatchTrigger'](patchObj, this);
    }
  }
}