Home Identifier Source Repository

src/store.js

import "array.prototype.find";
import Rx from "rx";
import AjaxAdapter from "./ajax-adapter";

export default class Store {

  /**
   * Creates a field definition for an attribute.
   *
   * @since 0.1.0
   * @param {string} [name] - Name of the property to map this field from.
   * @param {Object} [options] - An options object.
   * @param {string} [options.default] - Default value for this field.
   * @return {Object} - Field definition.
   */
  static attr(name, options) {
    if (name && typeof name === 'object') {
      return Store.attr(null, name);
    } else {
      return {
        type: "attr",
        default: options && options.default,
        deserialize: function (data, key) {
          return data.attributes && data.attributes[name || key];
        },
        serialize: function (resource, data, key) {
          data.attributes[name || key] = resource[key];
        }
      };
    }
  }

  /**
   * Creates a field definition for an has-one relationship.
   *
   * @since 0.1.0
   * @param {string} [name] - Name of the property to map this field from.
   * @param {Object} [options] - An options object.
   * @param {string} [options.inverse] - Name of the inverse relationship.
   * @return {Object} - Field definition.
   */
  static hasOne(name, options) {
    if (name && typeof name === 'object') {
      return Store.hasOne(null, name);
    } else {
      return {
        type: "has-one",
        inverse: options && options.inverse,
        deserialize: function (data, key) {
          name = name || key;
          if (data.relationships && data.relationships[name]) {
            if (data.relationships[name].data === null) {
              return null;
            } else if (data.relationships[name].data) {
              return this.find(data.relationships[name].data.type, data.relationships[name].data.id);
            }
          }
        },
        serialize: function serialize(resource, data, key) {
          if (resource[key] === null) {
            data.relationships[name || key] = null;
          } else if (resource[key]) {
            data.relationships[name || key] = {
              data: {
                type: resource[key].type,
                id: resource[key].id
              }
            };
          }
        }
      };
    }
  }

  /**
   * Creates a field definition for an has-many relationship.
   *
   * @since 0.1.0
   * @param {string} [name] - Name of the property to map this field from.
   * @param {Object} [options] - An options object.
   * @param {string} [options.inverse] - Name of the inverse relationship.
   * @return {Object} - Field definition.
   */
  static hasMany(name, options) {
    if (name && typeof name === 'object') {
      return Store.hasMany(null, name);
    } else {
      return {
        type: "has-many",
        default: [],
        inverse: options && options.inverse,
        deserialize: function (data, key) {
          name = name || key;
          if (data.relationships && data.relationships[name]) {
            if (data.relationships[name].data === null) {
              return [];
            } else if (data.relationships[name].data) {
              return data.relationships[name].data.map((c) => {
                return this.find(c.type, c.id);
              });
            }
          }
        },
        serialize: function serialize(resource, data, key) {
          if (resource[key]) {
            data.relationships[name || key] = {
              data: resource[key].map(x => {
                return { type: x.type, id: x.id };
              })
            };
          }
        }
      };
    }
  }

  constructor(adapter) {

    this._adapter = adapter;
    this._data = {};
    this._subject = new Rx.Subject();
    this._subscriptions = {};
    this._types = {};

    /**
     * An observable that will emit events when any resource in added, updated
     * or removed. The object passed to listeners will be in this format:
     *
     * <p><pre class="source-code">
     * { name: string, type: string, id: string, resource: object }
     * </pre></p>
     *
     * You can learn more about RxJS observables at the GitHub repo:
     * https://github.com/Reactive-Extensions/RxJS
     *
     * @type {Rx.Observable}
     * @since 0.6.0
     *
     * @example
     * let store = new Store();
     *
     * store.observable.filter(e => e.name === "added").subscribe(event => {
     *   console.log(event.name); // "added"
     *   console.log(event.type); // "products"
     *   console.log(event.id); // "1"
     *   console.log(event.resource); // Map {...}
     * });
     *
     * store.observable.filter(e => e.name === "updated").subscribe(event => {
     *   console.log(event.name); // "updated"
     *   console.log(event.type); // "products"
     *   console.log(event.id); // "1"
     *   console.log(event.resource); // Map {...}
     * });
     *
     * store.observable.filter(e => e.name === "removed").subscribe(event => {
     *   console.log(event.name); // "removed"
     *   console.log(event.type); // "products"
     *   console.log(event.id); // "1"
     *   console.log(event.resource); // null
     * });
     */
    this.observable = this._subject.asObservable();

  }

  /**
   * Add an individual resource to the store. This is used internally by the
   * `push()` method.
   *
   * @since 0.1.0
   * @param {!Object} object - A JSON API Resource Object to be added. See:
            http://jsonapi.org/format/#document-resource-objects
   */
  add(object) {
    if (object) {
      if (object.type && object.id) {
        let name = this._data[object.type] && this._data[object.type][object.id] ? "updated" : "added";
        let resource = this.find(object.type, object.id);
        let definition = this._types[object.type];
        Object.keys(definition).forEach(fieldName => {
          if (fieldName[0] !== "_") {
            this._addField(object, resource, definition, fieldName);
          }
        });
        this._subject.onNext({
          name: name,
          type: object.type,
          id: object.id,
          resource: resource
        });
      } else {
        throw new TypeError(`The data must have a type and id`);
      }
    } else {
      throw new TypeError(`You must provide data to add`);
    }
  }

  /**
   * Converts the given partial into a JSON API compliant representation.
   *
   * @since 0.5.0
   * @param {!string} [type] - The type of the resource. This can be omitted if the partial includes a type property.
   * @param {!string} [id] - The id of the resource. This can be omitted if the partial includes an id property.
   * @param {!object} partial - The data to convert.
   * @return {object} - JSON API version of the object.
   */
  convert(type, id, partial) {
    if (type && typeof type === "object") {
      return this.convert(type.type, type.id, type);
    } else if (id && typeof id === "object") {
      return this.convert(type, id.id, id);
    } else {
      let data = {
        type: type,
        attributes: {},
        relationships: {}
      };
      if (id) {
        data.id = id;
      }
      let definition = this._types[data.type];
      Object.keys(definition).forEach(fieldName => {
        if (fieldName[0] !== "_") {
          definition[fieldName].serialize(partial, data, fieldName);
        }
      });
      return data;
    }
  }

  /**
   * Attempts to create the resource through the adapter and adds it to  the
   * store if successful.
   *
   * @since 0.5.0
   * @param {!string} type - Type of resource.
   * @param {!Object} partial - Data to create the resource with.
   * @param {Object} [options] - Options to pass to the adapter.
   * @return {Rx.Observable}
   *
   * @example
   * let adapter = new Store.AjaxAdapter();
   * let store = new Store(adpater);
   * store.create("product", { title: "A Book" }).subscribe((product) => {
   *   console.log(product.title);
   * });
   */
  create(type, partial, options) {
    if (this._adapter) {
      return this._adapter.create(this, type, partial, options);
    } else {
      throw new Error("Adapter missing. Specify an adapter when creating the store: `var store = new Store(adapter);`");
    }
  }

  /**
   * Defines a type of resource.
   *
   * @since 0.2.0
   * @param {!string|string[]} names - Name(s) of the resource.
   * @param {!Object} definition - The resource's definition.
   */
  define(names, definition) {
    names = (names.constructor === Array) ? names : [ names ];
    if (definition) {
      definition._names = names;
      names.forEach(name => {
        if (!this._types[name]) {
          this._types[name] = definition;
        } else {
          throw new Error(`The type '${name}' has already been defined.`);
        }
      });
    } else {
      throw new Error(`You must provide a definition for the type '${names[0]}'.`);
    }
  }

  /**
   * Attempts to delete the resource through the adapter and removes it from
   * the store if successful.
   *
   * @since 0.5.0
   * @param {!string} type - Type of resource.
   * @param {!string} id - ID of resource.
   * @param {Object} [options] - Options to pass to the adapter.
   * @return {Rx.Observable}
   *
   * @example
   * let adapter = new Store.AjaxAdapter();
   * let store = new Store(adpater);
   * store.destroy("product", "1").subscribe(() => {
   *   console.log("Destroyed!");
   * });
   */
  destroy(type, id, options) {
    if (this._adapter) {
      return this._adapter.destroy(this, type, id, options);
    } else {
      throw new Error("Adapter missing. Specify an adapter when creating the store: `var store = new Store(adapter);`");
    }
  }

  /**
   * Finds a resource by type and id.
   *
   * NOTE: If the resource hasn't been loaded via an add() or push() call it
   * will be automatically created when find is called.
   *
   * @since 0.1.0
   * @param {!string} type - Type of the resource to find.
   * @param {!string} id - The id of the resource to find.
   * @return {Object} - The resource.
   */
  find(type, id) {
    if (type) {
      let definition = this._types[type];
      if (definition) {
        if (!this._data[type]) {
          let collection = {};
          definition._names.forEach(t => this._data[t] = collection);
        }
        if (id) {
          if (!this._data[type][id]) {
            this._data[type][id] = {
              _dependents: [],
              type: type,
              id: id
            };
            Object.keys(definition).forEach(key => {
              if (key[0] !== "_") {
                this._data[type][id][key] = definition[key].default;
              }
            });
          }
          return this._data[type][id];
        } else {
          // throw new TypeError(`You must provide an id`);
          /*eslint-disable*/
          console.warn([
            "Using the `store.find()` method to find an entire collection has been deprecated in favour of `store.findAll()`.",
            "For more information see: https://github.com/haydn/json-api-store/releases/tag/v0.7.0"
          ].join("\n"));
          /*eslint-enable*/
          return this.findAll(type);
        }
      } else {
        throw new TypeError(`Unknown type '${type}'`);
      }
    } else {
      throw new TypeError(`You must provide a type`);
    }
  }

  /**
   * Finds all the resources of a given type.
   *
   * @since 0.7.0
   * @param {!string} type - Type of the resource to find.
   * @return {Object[]} - An array of resources.
   */
  findAll(type) {
    if (type) {
      let definition = this._types[type];
      if (definition) {
        if (!this._data[type]) {
          let collection = {};
          definition._names.forEach(t => this._data[t] = collection);
        }
        return Object.keys(this._data[type]).map(x => this._data[type][x]);
      } else {
        throw new TypeError(`Unknown type '${type}'`);
      }
    } else {
      throw new TypeError(`You must provide a type`);
    }
  }

  /**
   * Attempts to load the given resource through the adapter and adds it to the
   * store if successful.
   *
   * @since 0.5.0
   * @param {!string} type - Type of resource.
   * @param {!string} id - ID of resource.
   * @param {Object} [options] - Options to pass to the adapter.
   * @return {Rx.Observable}
   *
   * @example
   * let adapter = new Store.AjaxAdapter();
   * let store = new Store(adpater);
   * store.load("products", "1").subscribe((product) => {
   *   console.log(product.title);
   * });
   */
  load(type, id, options) {
    if (!id || typeof id === "object") {
      /*eslint-disable*/
      console.warn([
        "Using the `store.load()` method to load an entire collection has been deprecated in favour of `store.loadAll()`.",
        "For more information see: https://github.com/haydn/json-api-store/releases/tag/v0.7.0"
      ].join("\n"));
      /*eslint-enable*/
    }
    if (this._adapter) {
      return this._adapter.load(this, type, id, options);
    } else {
      throw new Error("Adapter missing. Specify an adapter when creating the store: `var store = new Store(adapter);`");
    }
  }

  /**
   * Attempts to load all the resources of the given type through the adapter
   * and adds them to the store if successful.
   *
   * @since 0.7.0
   * @param {!string} type - Type of resource.
   * @param {Object} [options] - Options to pass to the adapter.
   * @return {Rx.Observable}
   *
   * @example
   * let adapter = new Store.AjaxAdapter();
   * let store = new Store(adpater);
   * store.loadAll("products").subscribe((products) => {
   *   console.log(products);
   * });
   */
  loadAll(type, options) {
    if (this._adapter) {
      return this._adapter.load(this, type, null, options);
    } else {
      throw new Error("Adapter missing. Specify an adapter when creating the store: `var store = new Store(adapter);`");
    }
  }

  /**
   * Unregister an event listener that was registered with on().
   *
   * @deprecated Use the <code>store.observable</code> property instead of this.
   * @since 0.4.0
   * @param {string} event - Name of the event.
   * @param {string} type - Name of resource to originally passed to on().
   * @param {string} [id] - ID of the resource to originally passed to on().
   * @param {function} callback - Function originally passed to on().
   */
  off(event, type, id, callback) {
    /*eslint-disable*/
    console.warn([
      "The `store.off()` method has been deprecated in favour of `store.observable`.",
      "For more information see: https://github.com/haydn/json-api-store/releases/tag/v0.6.0"
    ].join("\n"));
    /*eslint-enable*/
    if (event === "added" || event === "updated" || event === "removed") {
      if (this._types[type]) {
        if (id && ({}).toString.call(id) === '[object Function]') {
          this.off.call(this, event, type, null, id, callback);
        } else if (this._subscriptions[event] && this._subscriptions[event][type] && this._subscriptions[event][type][id || "*"]) {
          this._subscriptions[event][type][id || "*"].dispose();
          delete this._subscriptions[event][type][id || "*"];
        }
      } else {
        throw new Error(`Unknown type '${type}'`);
      }
    } else {
      throw new Error(`Unknown event '${event}'`);
    }
  }

  /**
   * Register an event listener: "added", "updated" or "removed".
   *
   * @deprecated Use the <code>store.observable</code> property instead of this.
   * @since 0.4.0
   * @param {string} event - Name of the event.
   * @param {string} type - Name of resource to watch.
   * @param {string} [id] - ID of the resource to watch.
   * @param {function} callback - Function to call when the event occurs.
   * @param {Object} [context] - Context in which to call the callback.
   */
  on(event, type, id, callback, context) {
    /*eslint-disable*/
    console.warn([
      "The `store.on()` method has been deprecated in favour of `store.observable`.",
      "For more information see: https://github.com/haydn/json-api-store/releases/tag/v0.6.0"
    ].join("\n"));
    /*eslint-enable*/
    if (event === "added" || event === "updated" || event === "removed") {
      if (this._types[type]) {
        if (id && ({}).toString.call(id) === '[object Function]') {
          this.on.call(this, event, type, null, id, callback);
        } else if (!this._subscriptions[event] || !this._subscriptions[event][type] || !this._subscriptions[event][type][id || "*"]) {
          let subscription = this._subject.filter(e => e.name === event);
          subscription = subscription.filter(e => this._types[type]._names.indexOf(e.type) !== -1);
          if (id) {
            subscription = subscription.filter(e => e.id === id);
          }
          subscription = subscription.map(e => this.find(e.type, e.id));
          this._subscriptions[event] = this._subscriptions[event] || {};
          if (!this._subscriptions[event][type]) {
            let obj = {};
            this._types[type]._names.forEach(type => {
              this._subscriptions[event][type] = obj;
            });
          }
          this._subscriptions[event][type][id || "*"] = subscription.subscribe(callback.bind(context));
        }
      } else {
        throw new Error(`Unknown type '${type}'`);
      }
    } else {
      throw new Error(`Unknown event '${event}'`);
    }
  }

  /**
   * Add a JSON API response to the store. This method can be used to handle a
   * successful GET or POST response from the server.
   *
   * @since 0.1.0
   * @param {Object} root - Top Level Object to push. See:
                            http://jsonapi.org/format/#document-top-level
   */
  push(root) {
    if (root.data.constructor === Array) {
      root.data.forEach(x => this.add(x));
    } else {
      this.add(root.data);
    }
    if (root.included) {
      root.included.forEach(x => this.add(x));
    }
  }

  /**
   * Remove a resource or collection of resources from the store.
   *
   * @since 0.1.0
   * @param {!string} type - Type of the resource(s) to remove.
   * @param {string} [id] - The id of the resource to remove. If omitted all
   *                        resources of the type will be removed.
   */
  remove(type, id) {
    if (type) {
      if (this._types[type]) {
        if (id) {
          let resource = this._data[type] && this._data[type][id];
          if (resource) {
            this._remove(resource);
            this._subject.onNext({
              name: "removed",
              type: type,
              id: id,
              resource: null
            });
          }
        } else {
          Object.keys(this._data[type]).forEach(id => this.remove(type, id));
        }
      } else {
        throw new TypeError(`Unknown type '${type}'`);
      }
    } else {
      throw new TypeError(`You must provide a type to remove`);
    }
  }

  /**
   * Attempts to update the resource through the adapter and updates it in  the
   * store if successful.
   *
   * @since 0.5.0
   * @param {!string} type - Type of resource.
   * @param {!string} id - ID of resource.
   * @param {!Object} partial - Data to update the resource with.
   * @param {Object} [options] - Options to pass to the adapter.
   * @return {Rx.Observable}
   *
   * @example
   * let adapter = new Store.AjaxAdapter();
   * let store = new Store(adpater);
   * store.update("product", "1", { title: "foo" }).subscribe((product) => {
   *   console.log(product.title);
   * });
   */
  update(type, id, partial, options) {
    if (this._adapter) {
      return this._adapter.update(this, type, id, partial, options);
    } else {
      throw new Error("Adapter missing. Specify an adapter when creating the store: `var store = new Store(adapter);`");
    }
  }

  _addField(object, resource, definition, fieldName) {
    var field = definition[fieldName];
    var newValue = field.deserialize.call(this, object, fieldName);
    if (typeof newValue !== "undefined") {
      if (field.type === "has-one") {
        if (resource[fieldName]) {
          this._removeInverseRelationship(resource, fieldName, resource[fieldName], field);
        }
        if (newValue) {
          this._addInverseRelationship(resource, fieldName, newValue, field);
        }
      } else if (field.type === "has-many") {
        resource[fieldName].forEach(r => {
          if (resource[fieldName].indexOf(r) !== -1) {
            this._removeInverseRelationship(resource, fieldName, r, field);
          }
        });
        newValue.forEach(r => {
          this._addInverseRelationship(resource, fieldName, r, field);
        });
      }
      resource[fieldName] = newValue;
    }
  }

  _addInverseRelationship(sourceResource, sourceFieldName, targetResource, sourceField) {
    var targetDefinition = this._types[targetResource.type];
    var sourceDefinition = this._types[sourceResource.type];
    if (targetDefinition) {
      let targetFieldName = [ sourceField.inverse ].concat(sourceDefinition._names).find(x => targetDefinition[x]);
      let targetField = targetDefinition && targetDefinition[targetFieldName];
      targetResource._dependents.push({ type: sourceResource.type, id: sourceResource.id, fieldName: sourceFieldName });
      if (targetField) {
        if (targetField.type === "has-one") {
          sourceResource._dependents.push({ type: targetResource.type, id: targetResource.id, fieldName: targetFieldName });
          targetResource[targetFieldName] = sourceResource;
        } else if (targetField.type === "has-many") {
          sourceResource._dependents.push({ type: targetResource.type, id: targetResource.id, fieldName: targetFieldName });
          if (targetResource[targetFieldName].indexOf(sourceResource) === -1) {
            targetResource[targetFieldName].push(sourceResource);
          }
        } else if (targetField.type === "attr") {
          throw new Error(`The the inverse relationship for '${sourceFieldName}' is an attribute ('${targetFieldName}')`);
        }
      } else if (sourceField.inverse) {
        throw new Error(`The the inverse relationship for '${sourceFieldName}' is missing ('${sourceField.inverse}')`);
      }
    }
  }

  _remove(resource) {
    resource._dependents.forEach(dependent => {
      let dependentResource = this._data[dependent.type][dependent.id];
      switch (this._types[dependent.type][dependent.fieldName].type) {
        case "has-one": {
          dependentResource[dependent.fieldName] = null;
          break;
        }
        case "has-many": {
          let index = dependentResource[dependent.fieldName].indexOf(resource);
          if (index !== -1) {
            dependentResource[dependent.fieldName].splice(index, 1);
          }
          break;
        }
        default: {
          break;
        }
      }
      // TODO: This only needs to be run once for each dependent.
      dependentResource._dependents = dependentResource._dependents.filter(d => {
        return !(d.type === resource.type && d.id === resource.id);
      });
    });
    delete this._data[resource.type][resource.id];
  }

  _removeInverseRelationship(sourceResource, sourceFieldName, targetResource, sourceField) {
    var targetDefinition = this._types[targetResource.type];
    var targetFieldName = sourceField.inverse || sourceResource.type;
    var targetField = targetDefinition && targetDefinition[targetFieldName];
    targetResource._dependents = targetResource._dependents.filter(r => {
      return !(r.type === sourceResource.type && r.id === sourceResource.id && r.fieldName === sourceFieldName);
    });
    if (targetField) {
      if (targetField.type === "has-one") {
        sourceResource._dependents = sourceResource._dependents.filter(r => {
          return !(r.type === targetResource.type && r.id === targetResource.id && r.fieldName === targetFieldName);
        });
        targetResource[targetFieldName] = null;
      } else if (targetField.type === "has-many") {
        sourceResource._dependents = sourceResource._dependents.filter(r => {
          return !(r.type === targetResource.type && r.id === targetResource.id && r.fieldName === targetFieldName);
        });
        targetResource[targetFieldName] = targetResource[targetFieldName].filter(r => {
          return r !== sourceResource;
        });
      } else if (targetField.type === "attr") {
        throw new Error(`The the inverse relationship for '${sourceFieldName}' is an attribute ('${targetFieldName}')`);
      }
    } else if (sourceField.inverse) {
      throw new Error(`The the inverse relationship for '${sourceFieldName}' is missing ('${sourceField.inverse}')`);
    }
  }

}

Store.Rx = Rx;
Store.AjaxAdapter = AjaxAdapter;