Source: DataStore.js

import utils from './utils'
import {
  belongsToType,
  hasManyType,
  hasOneType
} from './decorators'
import Container from './Container'
import LinkedCollection from './LinkedCollection'

const DATASTORE_DEFAULTS = {
  /**
   * Whether relations should be linked for records that are in the datastore.
   *
   * Defaults to `true` in the browser and `false` in Node.js
   *
   * @name DataStore#linkRelations
   * @type {boolean}
   */
  linkRelations: utils.isBrowser
}

const props = {
  constructor: function DataStore (opts) {
    const self = this
    utils.classCallCheck(self, DataStore)
    DataStore.__super__.call(self, opts)

    self.collectionClass = self.collectionClass || LinkedCollection
    self._collections = {}
    utils.fillIn(self, DATASTORE_DEFAULTS)
    self._pendingQueries = {}
    self._completedQueries = {}
    return self
  },

  _callSuper (method, ...args) {
    return this.constructor.__super__.prototype[method].apply(this, args)
  },

  /**
   * TODO
   *
   * @name DataStore#_end
   * @method
   * @private
   * @param {string} name Name of the {@link LinkedCollection} to which to
   * add the data.
   * @param {Object} data TODO.
   * @param {Object} [opts] Configuration options.
   * @return {(Object|Array)} Result.
   */
  _end (name, data, opts) {
    if (opts.raw) {
      data.data = this.getCollection(name).add(data.data, opts)
      return data
    } else {
      data = this.getCollection(name).add(data, opts)
    }
    return data
  },

  cachedFind (name, id, opts) {
    return this.get(name, id, opts)
  },

  cachedFindAll (name, query, opts) {
    const self = this
    if (self._completedQueries[name][self.hashQuery(name, query, opts)]) {
      return self.filter(name, query, opts)
    }
  },

  /**
   * TODO
   *
   * @name DataStore#create
   * @method
   * @param {string} name Name of the {@link Mapper} to target.
   * @param {Object} record Passed to {@link Mapper#create}.
   * @param {Object} [opts] Passed to {@link Mapper#create}. See
   * {@link Mapper#create} for more configuration options.
   * @return {Promise}
   */
  create (name, record, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('create', name, record, opts).then(function (data) {
      return self._end(name, data, opts)
    })
  },

  /**
   * TODO
   *
   * @name DataStore#createMany
   * @method
   * @param {string} name Name of the {@link Mapper} to target.
   * @param {Array} records Passed to {@link Mapper#createMany}.
   * @param {Object} [opts] Passed to {@link Mapper#createMany}. See
   * {@link Mapper#createMany} for more configuration options.
   * @return {Promise}
   */
  createMany (name, records, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('createMany', name, records, opts).then(function (data) {
      return self._end(name, data, opts)
    })
  },

  defineMapper (name, opts) {
    const self = this
    const mapper = utils.getSuper(self).prototype.defineMapper.call(self, name, opts)
    self._pendingQueries[name] = {}
    self._completedQueries[name] = {}
    mapper.relationList || Object.defineProperty(mapper, 'relationList', { value: [] })

    // The datastore uses a subclass of Collection that is "datastore-aware"
    const collection = self._collections[name] = new self.collectionClass(null, { // eslint-disable-line
      // Make sure the collection has somewhere to store "added" timestamps
      _added: {},
      // Give the collection a reference to this datastore
      datastore: self,
      // The mapper tied to the collection
      mapper
    })

    const schema = mapper.schema || {}
    const properties = schema.properties || {}
    // TODO: Make it possible index nested properties?
    utils.forOwn(properties, function (opts, prop) {
      if (opts.indexed) {
        collection.createIndex(prop)
      }
    })

    // Create a secondary index on the "added" timestamps of records in the
    // collection
    collection.createIndex('addedTimestamps', ['$'], {
      fieldGetter (obj) {
        return collection._added[collection.recordId(obj)]
      }
    })

    const linkRelations = self.linkRelations

    if (linkRelations) {
      mapper.relationList.forEach(function (def) {
        const relation = def.relation
        const localField = def.localField
        const path = `links.${localField}`
        const foreignKey = def.foreignKey
        const type = def.type
        const link = utils.isUndefined(def.link) ? linkRelations : def.link
        const updateOpts = { index: foreignKey }
        let descriptor

        if (type === belongsToType) {
          if (!collection.indexes[foreignKey]) {
            collection.createIndex(foreignKey)
          }

          descriptor = {
            get () {
              const _self = this
              if (!_self._get('$') || !link) {
                return _self._get(path)
              }
              const key = def.getForeignKey(_self)
              const item = utils.isUndefined(key) ? undefined : self.getCollection(relation).get(key)
              _self._set(path, item)
              return item
            },
            set (record) {
              const _self = this
              _self._set(path, record)
              def.setForeignKey(_self, record)
              collection.updateIndex(_self, updateOpts)
              return def.getLocalField(_self)
            }
          }
        } else if (type === hasManyType) {
          const localKeys = def.localKeys
          const foreignKeys = def.foreignKeys

          // TODO: Handle case when belongsTo relation isn't ever defined
          if (self._collections[relation] && foreignKey && !self.getCollection(relation).indexes[foreignKey]) {
            self.getCollection(relation).createIndex(foreignKey)
          }

          descriptor = {
            get () {
              const _self = this
              if (!_self._get('$') || !link) {
                return _self._get(path)
              }
              const key = def.getForeignKey(_self)
              let items
              const relationCollection = self.getCollection(relation)

              if (foreignKey) {
                // Really fast retrieval
                items = relationCollection.getAll(key, {
                  index: foreignKey
                })
              } else if (localKeys) {
                const keys = utils.get(_self, localKeys) || []
                const args = utils.isArray(keys) ? keys : Object.keys(keys)
                // Really fast retrieval
                items = relationCollection.getAll.apply(relationCollection, args)
              } else if (foreignKeys) {
                const query = {}
                utils.set(query, `where.${foreignKeys}.contains`, key)
                // Make a much slower retrieval
                items = relationCollection.filter(query)
              }

              _self._set(path, items)
              return items
            },
            set (records) {
              const _self = this
              const key = collection.recordId(_self)
              const relationCollection = self.getCollection(relation)
              _self._set(path, records)

              if (foreignKey) {
                def.setForeignKey(_self, records)
                if (utils.isArray(records)) {
                  records.forEach(function (record) {
                    relationCollection.updateIndex(record, updateOpts)
                  })
                }
              } if (localKeys) {
                utils.set(_self, localKeys, records.map(function (record) {
                  return relationCollection.recordId(record)
                }))
              } else if (foreignKeys) {
                records.forEach(function (record) {
                  const keys = utils.get(record, foreignKeys)
                  if (keys) {
                    if (keys.indexOf(key) === -1) {
                      keys.push(key)
                    }
                  } else {
                    utils.set(record, foreignKeys, [key])
                  }
                })
              }
              return def.getLocalField(_self)
            }
          }
        } else if (type === hasOneType) {
          descriptor = {
            get () {
              const _self = this
              if (!_self._get('$') || !link) {
                return _self._get(path)
              }
              const key = def.getForeignKey(_self)
              const items = self.getCollection(relation).getAll(key, {
                index: foreignKey
              })
              const item = items.length ? items[0] : undefined
              _self._set(path, item)
              return item
            },
            set (record) {
              const _self = this
              _self._set(path, record)
              def.setForeignKey(_self, record)
              self.getCollection(relation).updateIndex(record, updateOpts)
              return def.getLocalField(_self)
            }
          }
        }

        if (descriptor) {
          descriptor.enumerable = utils.isUndefined(def.enumerable) ? true : def.enumerable
          if (def.get) {
            let origGet = descriptor.get
            descriptor.get = function () {
              return def.get(def, this, (...args) => origGet.apply(this, args))
            }
          }
          if (def.set) {
            let origSet = descriptor.set
            descriptor.set = function (related) {
              return def.set(def, this, related, (value) => origSet.call(this, value === undefined ? related : value))
            }
          }
          Object.defineProperty(mapper.recordClass.prototype, localField, descriptor)
        }
      })
    }

    return mapper
  },

  /**
   * TODO
   *
   * @name DataStore#destroy
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {(string|number)} id - Passed to {@link Mapper#destroy}.
   * @param {Object} [opts] - Passed to {@link Mapper#destroy}. See
   * {@link Mapper#destroy} for more configuration options.
   * @return {Promise}
   */
  destroy (name, id, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('destroy', name, id, opts).then(function (data) {
      if (opts.raw) {
        data.data = self.getCollection(name).remove(id, opts)
      } else {
        data = self.getCollection(name).remove(id, opts)
      }
      delete self._pendingQueries[name][id]
      delete self._completedQueries[name][id]
      return data
    })
  },

  /**
   * TODO
   *
   * @name DataStore#destroyAll
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {Object} [query] - Passed to {@link Mapper#destroyAll}.
   * @param {Object} [opts] - Passed to {@link Mapper#destroyAll}. See
   * {@link Mapper#destroyAll} for more configuration options.
   * @return {Promise}
   */
  destroyAll (name, query, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('destroyAll', name, query, opts).then(function (data) {
      if (opts.raw) {
        data.data = self.getCollection(name).removeAll(query, opts)
      } else {
        data = self.getCollection(name).removeAll(query, opts)
      }
      const hash = self.hashQuery(name, query, opts)
      delete self._pendingQueries[name][hash]
      delete self._completedQueries[name][hash]
      return data
    })
  },

  eject (id, opts) {
    return this.remove(id, opts)
  },

  ejectAll (query, opts) {
    return this.removeAll(query, opts)
  },

  /**
   * TODO
   *
   * @name DataStore#find
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {(string|number)} id - Passed to {@link Mapper#find}.
   * @param {Object} [opts] - Passed to {@link Mapper#find}.
   * @return {Promise}
   */
  find (name, id, opts) {
    const self = this
    opts || (opts = {})
    const pendingQuery = self._pendingQueries[name][id]

    utils.fillIn(opts, self.getMapper(name))

    if (pendingQuery) {
      return pendingQuery
    }
    const item = self.cachedFind(name, id, opts)
    let promise

    if (opts.force || !item) {
      promise = self._pendingQueries[name][id] = self._callSuper('find', name, id, opts).then(function (data) {
        delete self._pendingQueries[name][id]
        return self._end(name, data, opts)
      }, function (err) {
        delete self._pendingQueries[name][id]
        return utils.reject(err)
      }).then(function (data) {
        self._completedQueries[name][id] = new Date().getTime()
        return data
      })
    } else {
      promise = utils.resolve(item)
    }
    return promise
  },

  /**
   * TODO
   *
   * @name DataStore#findAll
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {Object} [query] - Passed to {@link Model.findAll}.
   * @param {Object} [opts] - Passed to {@link Model.findAll}.
   * @return {Promise}
   */
  findAll (name, query, opts) {
    const self = this
    opts || (opts = {})
    const hash = self.hashQuery(name, query, opts)
    const pendingQuery = self._pendingQueries[name][hash]

    utils.fillIn(opts, self.getMapper(name))

    if (pendingQuery) {
      return pendingQuery
    }

    const items = self.cachedFindAll(name, query, opts)
    let promise

    if (opts.force || !items) {
      promise = self._pendingQueries[name][hash] = self._callSuper('findAll', name, query, opts).then(function (data) {
        delete self._pendingQueries[name][hash]
        return self._end(name, data, opts)
      }, function (err) {
        delete self._pendingQueries[name][hash]
        return utils.reject(err)
      }).then(function (data) {
        self._completedQueries[name][hash] = new Date().getTime()
        return data
      })
    } else {
      promise = utils.resolve(items)
    }
    return promise
  },

  /**
   * TODO
   *
   * @name DataStore#getCollection
   * @method
   * @param {string} name Name of the {@link LinkedCollection} to retrieve.
   * @return {LinkedCollection}
   */
  getCollection (name) {
    const collection = this._collections[name]
    if (!collection) {
      throw new ReferenceError(`${name} is not a registered collection!`)
    }
    return collection
  },

  hashQuery (name, query, opts) {
    return utils.toJson(query)
  },

  inject (records, opts) {
    return this.add(records, opts)
  },

  remove (name, id, opts) {
    const self = this
    const record = self.getCollection(name).remove(id, opts)
    if (record) {
      self.removeRelated(name, [record], opts)
    }
    return record
  },

  removeAll (name, query, opts) {
    const self = this
    const records = self.getCollection(name).removeAll(query, opts)
    if (records.length) {
      self.removeRelated(name, records, opts)
    }
    return records
  },

  removeRelated (name, records, opts) {
    const self = this
    utils.forEachRelation(self.getMapper(name), opts, function (def, optsCopy) {
      records.forEach(function (record) {
        let relatedData
        let query
        if (def.foreignKey && (def.type === 'hasOne' || def.type === 'hasMany')) {
          query = { [def.foreignKey]: def.getForeignKey(record) }
        } else if (def.type === 'hasMany' && def.localKeys) {
          query = {
            where: {
              [def.getRelation().idAttribute]: {
                'in': utils.get(record, def.localKeys)
              }
            }
          }
        } else if (def.type === 'hasMany' && def.foreignKeys) {
          query = {
            where: {
              [def.foreignKeys]: {
                'contains': def.getForeignKey(record)
              }
            }
          }
        } else if (def.type === 'belongsTo') {
          relatedData = self.remove(def.relation, def.getForeignKey(record), optsCopy)
        }
        if (query) {
          relatedData = self.removeAll(def.relation, query, optsCopy)
        }
        if (relatedData) {
          if (utils.isArray(relatedData) && !relatedData.length) {
            return
          }
          if (def.type === 'hasOne') {
            relatedData = relatedData[0]
          }
          def.setLocalField(record, relatedData)
        }
      })
    })
  },

  /**
   * TODO
   *
   * @name DataStore#update
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {(string|number)} id - Passed to {@link Mapper#update}.
   * @param {Object} record - Passed to {@link Mapper#update}.
   * @param {Object} [opts] - Passed to {@link Mapper#update}. See
   * {@link Mapper#update} for more configuration options.
   * @return {Promise}
   */
  update (name, id, record, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('update', name, id, record, opts).then(function (data) {
      return self._end(name, data, opts)
    })
  },

  /**
   * TODO
   *
   * @name DataStore#updateAll
   * @method
   * @param {string} name - Name of the {@link Mapper} to target.
   * @param {Object} props - Passed to {@link Mapper#updateAll}.
   * @param {Object} [query] - Passed to {@link Mapper#updateAll}.
   * @param {Object} [opts] - Passed to {@link Mapper#updateAll}. See
   * {@link Mapper#updateAll} for more configuration options.
   * @return {Promise}
   */
  updateAll (name, props, query, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('updateAll', name, query, props, opts).then(function (data) {
      return self._end(name, data, opts)
    })
  },

  /**
   * TODO
   *
   * @name DataStore#updateMany
   * @method
   * @param {string} name Name of the {@link Mapper} to target.
   * @param {(Object[]|Record[])} records Passed to {@link Mapper#updateMany}.
   * @param {Object} [opts] Passed to {@link Mapper#updateMany}. See
   * {@link Mapper#updateMany} for more configuration options.
   * @return {Promise}
   */
  updateMany (name, records, opts) {
    const self = this
    opts || (opts = {})
    return self._callSuper('updateMany', name, records, opts).then(function (data) {
      return self._end(name, data, opts)
    })
  }
}

const toProxy = [
  'add',
  'between',
  'createIndex',
  'filter',
  'get',
  'getAll',
  'query',
  'toJson'
]

toProxy.forEach(function (method) {
  props[method] = function (name, ...args) {
    return this.getCollection(name)[method](...args)
  }
})

/**
 * The `DataStore` class is an extension of {@link Container}. Not only does
 * `DataStore` manage mappers, but also collections. `DataStore` implements the
 * asynchronous {@link Mapper} methods, such as {@link Mapper#find} and
 * {@link Mapper#create}. If you use the asynchronous `DataStore` methods
 * instead of calling them directly on the mappers, then the results of the
 * method calls will be inserted into the store's collections. You can think of
 * a `DataStore` as an [Identity Map](https://en.wikipedia.org/wiki/Identity_map_pattern)
 * for the [ORM](https://en.wikipedia.org/wiki/Object-relational_mapping)
 * (the Mappers).
 *
 * ```javascript
 * import {DataStore} from 'js-data'
 * ```
 *
 * @example
 * import {DataStore} from 'js-data'
 * import HttpAdapter from 'js-data-http'
 * const store = new DataStore()
 * const UserMapper = store.defineMapper('user')
 *
 * // Call "find" on "UserMapper" (Stateless ORM)
 * UserMapper.find(1).then(function (user) {
 *   // retrieved a "user" record via the http adapter, but that's it
 *
 *   // Call "find" on "store" for the "user" mapper (Stateful DataStore)
 *   return store.find('user', 1)
 * }).then(function (user) {
 *   // not only was a "user" record retrieved, but it was added to the
 *   // store's "user" collection
 *   const cachedUser = store.getCollection('user').get(1)
 *   user === cachedUser // true
 * })
 *
 * @class DataStore
 * @extends Container
 * @param {Object} [opts] Configuration options. See {@link Container}.
 * @return {DataStore}
 */
export default Container.extend(props)