yahoo/elide-js

View on GitHub
lib/elide.js

Summary

Maintainability
C
1 day
Test Coverage
/*********************************************************************************
 * Copyright 2015 Yahoo Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 ********************************************************************************/
'use strict';

import Query from './query';
import MemoryDatastore from './datastores/memorydatastore';
import JsonApiDatastore from './datastores/jsonapidatastore';

import clone from './helpers/clone';
import { objectLooksLikePromise } from './helpers/checkpromise';

// jscs:disable maximumLineLength
export let ERROR_INVALID_SCHEMA = 'Invalid Elide schema format.';
export let ERROR_INVALID_OPTIONS = 'Invalid Elide options specified.';
export let ERROR_INVALID_PROMISE = 'Invalid Promise/A+ library specified.';
export let ERROR_INVALID_STORES = 'Invalid stores section found in schema.';
export let ERROR_NUM_STORES = 'Elide schema.stores must describe at least one store.';
export let ERROR_INVALID_MODELS = 'Invalid models section found in schema.';
export let ERROR_NUM_MODELS = 'Elide schema.models must describe at least one model.';
export let ERROR_BAD_STORE = 'Elide model "${modelName}" must specify a valid store.';
export let ERROR_BAD_STORE_TYPE = 'Unknown store type "${storeType}".';
export let ERROR_NO_LINK_TYPE = 'Elide model "${modelName}" specifies a link without a type.';
export let ERROR_BAD_LINK_TYPE = 'Invalid link type "${linkType}".';
export let ERROR_NO_LINK_MODEL = 'Elide model "${modelName}" specifies a link to an unknown model.';
export let ERROR_UNKNOWN_MODEL = 'Elide model "${modelName}" does not exist.';
export let ERROR_BAD_LINK_MODEL = 'Elide model "${modelName}" specifies a link to an unknown model.';
export let ERROR_DANGLING_MODEL = 'Elide model "${modelName}" cannot be rooted.';
export let ERROR_UNKNOWN_UPSTREAM_STORE = 'Cannot set upstream store to "${storeName}", "${storeName}" not defined.';
// jscs:enable maximumLineLength

class Elide {
  constructor(schema, options) {
    if (typeof schema !== 'object') {
      throw new Error(ERROR_INVALID_SCHEMA);
    }

    if (typeof options !== 'object') {
      throw new Error(ERROR_INVALID_OPTIONS);
    }
    if (!objectLooksLikePromise(options.promise)) {
      throw new Error(ERROR_INVALID_PROMISE);
    }

    if (typeof schema.stores !== 'object') {
      throw new Error(ERROR_INVALID_STORES);
    }
    if (Object.keys(schema.stores).length === 0) {
      throw new Error(ERROR_NUM_STORES);
    }

    if (typeof schema.models !== 'object') {
      throw new Error(ERROR_INVALID_MODELS);
    }
    if (Object.keys(schema.models).length === 0) {
      throw new Error(ERROR_NUM_MODELS);
    }

    this._promise = options.promise;
    this._stores = {};
    this._modelToStoreMap = {};
    this._storesThatCommit = [];
    this._configureModels(schema.models, Object.keys(schema.stores));
    this._configureStores(schema.stores);

    this.auth = {
      addQueryParameter: this._addQueryParameter.bind(this),
      addRequestHeader: this._addRequestHeader.bind(this),
      reset: this._resetAuthData.bind(this)
    };
  }

  _configureModels(models, storeNames) {
    let rootable = {};
    let canRoot = {};

    // verify metadata and links
    Object.keys(models).map((modelName) => {
      let model = models[modelName];

      if (!model.meta || !model.meta.store) {
        throw new Error(ERROR_BAD_STORE
                        .replace('${modelName}', modelName));
      }
      if (storeNames.indexOf(model.meta.store) === -1) {
        throw new Error(ERROR_BAD_STORE
                        .replace('${modelName}', modelName));
      }
      this._modelToStoreMap[modelName] = model.meta.store;

      if (model.meta.isRootObject === true) {
        rootable[modelName] = true;
      }

      // this is the list of models that can be rooted via the current model
      canRoot[modelName] = [];
      model.links = model.links || {};
      Object.keys(model.links).map(function(linkName) {
        let link = model.links[linkName];

        let linkType = link.type;
        if (!linkType) {
          throw new Error(ERROR_NO_LINK_TYPE
                          .replace('${modelName}', modelName));
        }
        if (linkType !== 'hasOne' && linkType !== 'hasMany') {
          throw new Error(ERROR_BAD_LINK_TYPE
                          .replace('${linkType}', linkType));
        }

        let modelType = link.model;
        if (!modelType) {
          throw new Error(ERROR_NO_LINK_MODEL
                          .replace('${modelName}', modelName));
        }
        if (!models[modelType]) {
          throw new Error(ERROR_BAD_LINK_MODEL
                          .replace('${modelName}', modelName));
        }

        canRoot[modelName].push(modelType);
      });
    });

    // verify rootability by recursively checking children
    var rootChildren = function rootChildren(modelName) {
      rootable[modelName] = true;

      let children = canRoot[modelName];
      children.map(function(childName) {
        rootChildren(childName);
      });
    };
    Object.keys(rootable).map(function(rootableModel) {
      rootChildren(rootableModel);
    });

    Object.keys(models).map(function(model) {
      if (rootable[model] !== true) {
        throw new Error(ERROR_DANGLING_MODEL
                        .replace('${modelName}', model));
      }
    });

    this._models = clone(models);
  }

  _configureStores(stores) {
    Object.keys(stores).map((storeName) => {
      let storeDef = stores[storeName];
      let storeType = storeDef.type;

      let promise = this._promise;
      let ttl = storeDef.ttl;
      let baseURL = storeDef.baseURL;
      let models = clone(this._models);
      let store;

      switch (storeType) {
        case 'memory':
          store = new MemoryDatastore(promise, ttl, baseURL, models);
          this._storesThatCommit.push(storeName);
          break;

        case 'jsonapi':
          store = new JsonApiDatastore(promise, ttl, baseURL, models);
          break;

        default:
          throw new Error(ERROR_BAD_STORE_TYPE
            .replace('${storeType}', storeType));
      }

      this._stores[storeName] = store;
    });

    Object.keys(stores).map((storeName) => {
      let upstreamName = stores[storeName].upstream;
      if (!upstreamName) {
        return;
      }

      let store = this._stores[storeName];
      let upstream = this._stores[upstreamName];

      if (upstream === undefined) {
        throw new Error(ERROR_UNKNOWN_UPSTREAM_STORE
          .replace(/\$\{storeName\}/g, upstreamName));
      }

      store.setUpstream(upstream);
    });
  }

  _getStoreForModel(model) {
    if (!this._modelToStoreMap.hasOwnProperty(model)) {
      throw new Error(ERROR_UNKNOWN_MODEL
        .replace('${modelName}', model));
    }
    return this._stores[this._modelToStoreMap[model]];
  }

  /**
   * Add a query parameter to those stores that compare
   *
   * @param  {String}           key   - the name of the qurey parameter
   * @param  {String}           value - the value of the query parameter
   */
  _addQueryParameter(key, value) {
    Object.keys(this._stores).forEach((store) => {
      this._stores[store].addQueryParameter(key, value);
    });
  }

  /**
   * Add a request header to those stores that care
   *
   * @param  {String}          key   - the name of the header
   * @param  {String}          value - the value of the header
   */
  _addRequestHeader(key, value) {
    Object.keys(this._stores).forEach((store) => {
      this._stores[store].addRequestHeader(key, value);
    });
  }

  /**
   * clear auth data from stores that track it
   *
   */
  _resetAuthData() {
    Object.keys(this._stores).forEach((store) => {
      store.clearAuthData();
    });
  }

  /*
   * PUBLIC INTERFACE
   */

  find(model, id, opts) {
    if (id instanceof Object) {
      opts = id
      id = undefined
    }

    opts = opts || {};
    let store = this._getStoreForModel(model);
    return new Query(store, model, id, opts);
  }


  create(model, state) {
    let store = this._getStoreForModel(model);
    return store.create(model, state);
  }

  update(model, state) {
    let store = this._getStoreForModel(model);
    return store.update(model, state);
  }

  delete(model, state) {
    let store = this._getStoreForModel(model);
    return store.delete(model, state);
  }

  commit() {
    return this._promise.all(this._storesThatCommit.map((storeName) => {
      return this._stores[storeName].commit();
    }));
  }

  dehydrate() {
    let state = {};

    state.storeMap = JSON.stringify(this._modelToStoreMap);
    state.stores = {};
    Object.keys(this._stores).forEach((store) => {
      state.stores[store] = this._stores[store].dehydrate();
    });

    return state;
  }

  rehydrate(state) {
    let storeMap = JSON.stringify(this._modelToStoreMap);
    if (state.storeMap !== storeMap) {
      // debug('Caution: restoring state from a different instance has undefined behavior');
    }

    Object.keys(state.stores).forEach((store) => {
      this._stores[store].rehydrate(state.stores[store]);
    });
  }
}

export default Elide;