model.js

import './polyfill';
import Store from './store';
import Registry from './registry';

/**
 * @private
 */
const invalidAttribute = (field) => (
  new Error(`${field} needs an attribute type`)
);

/**
 * @private
 */
const defineAttribute = (object, field, attribute) => {
  Object.defineProperty(object, field, {
    get() {
      return attribute.cast(this.data.attributes[attribute.name || field]);
    }
  });
};

/**
 * @private
 */
const defineRelationship = (object, field, attribute) => {
  Object.defineProperty(object, field, {
    get() {
      const { isArray, resource } = this.constructor.attributes[field];
      const model = Registry.get(resource);
      if (!model) {
        throw new Error(`Unregistered model: ${resource}`);
      }
      const data = this.data.relationships[attribute.name || field].data;

      return isArray
        ? Store.where({ id: data.map((d) => d.id) }, { model })
        : Store.find({ id: data.id }, { model });
    }
  });
};

/**
 * Define JSON-API attributes and relationships
 * @private
 */
const defineMethods = (object, attributes) => {
  const relationships = {};

  Object.entries(attributes).forEach(([field, attribute]) => {
    switch (attribute.type) {
      case 'attribute':
        defineAttribute(object.prototype, field, attribute);
        break;
      case 'relationship':
        defineRelationship(object.prototype, field, attribute);
        relationships[attribute.resource] = { key: field, ...attribute };
        break;
      default:
        throw invalidAttribute(field);
    }
  });

  return relationships;
};

/**
 * @private
 */
const copyClassName = (from, to) => {
  Object.defineProperty(to, 'name', {
    value: from.name,
    writable: false,
    enumerable: false,
    configurable: false,
  });
};

/**
 * Helper function to create endpoint URIs
 * @private
 * @example
 * const obj = { type: 'animal', id: 5 }
 * parseParams(obj, '/api/v1/:type/:id')
 * // => '/api/v1/animal/5'
 */
export const parseParams = (context, string) => {
  const regexp = /:[^/.]+/g;
  return string.replace(regexp, (match) => context[match.substring(1)]);
};

/**
  * Defines a model
  * @alias module:active-redux.define
  * @example
  * const Person = ActiveRedux.define('people', class Person {});
  * @param {string} type - JSON-API type for the model
  * @param {Class} [model] - Class to extend
  * @return {Model}
  */
export default function define(type, model = class {}) {
  /**
   * Active Redux Model
   */
  const Model = class extends model {

    /**
    * JSON-API type of the model
    * @private
    */
    static type = type;

    /**
    * Attributes of a model, for which methods will be defined
    * @example
    * import Model, { Attr } from 'active-redux';
    *
    * const Person = Model.define('people', class Person {
    *   static attributes = {
    *     name: Attr.string(),
    *   }
    * });
    *
    * const joe = new Person({ attributes: { name: "Joe" } });
    * joe.name // => "Joe"
    */
    static attributes = model.attributes || {};

    /**
    * Endpoints that will be queried. Can be overwritten.
    * @private
    */
    static endpoints = model.endpoints || {
      create: ':type',
      read: ':type',
      update: ':type/:id',
      delete: ':type/:id',
    };

    /**
    * @private
    */
    static relationships = {};

    /**
    * Gets all of that resource
    * @example
    * Person.all()
    * // => Promise<Array<Person>>
    * @return {Promise<Array<this>>} Array of model instances
    */
    static all() {
      return Store.all({ model: this });
    }

    /**
    * Queries the store for that resource
    * @example
    * Person.where({ name: "Joe" })
    * // => Promise<Array<Person>>
    * @param {Object} query - Query for the store
    * @param {Object} [options]
    * @param {boolean} [options.remote] - Call to API if no records found?
    * @return {Promise<Array<this>>} Array of model instances
    */
    static where(query = {}, options = {}) {
      return Store.where(query, { model: this, ...options });
    }

    /**
    * Gets one of that resource
    * @example
    * Person.find({ id: 5 })
    * // => Promise<Person>
    * @param {Object} query - Query for the store
    * @param {Object} [options]
    * @param {boolean} [options.remote] - Call to API if no records found?
    * @return {Promise<(this|null)>} A model instance
    */
    static find(query = {}, options = {}) {
      return Store.find(query, { model: this, ...options });
    }

    /**
    * Find the endpoint for a specific action
    * @private
    */
    static endpoint(action, params = this) {
      return parseParams(params, this.endpoints[action]);
    }

    /**
    * @private
    * @see Model.endpoint
    */
    endpoint(action) {
      return this.constructor.endpoint(action, this);
    }

    constructor(data) {
      super(data);
      this.data = data;
    }

    /**
     * @return {string} JSON-API Resource ID
     */
    get id() {
      return this.data.id;
    }

    /**
     * @return {string} JSON-API Resource Type
     */
    get type() {
      return this.constructor.type;
    }
  };

  copyClassName(model, Model);
  Model.relationships = defineMethods(Model, Model.attributes);
  Registry.register(Model);
  return Model;
}