Source: DataStore.js

import utils, { safeSetLink, safeSetProp } from './utils'

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

const DATASTORE_DEFAULTS = {
  /**
   * Whether in-memory relations should be unlinked from records after they are
   * destroyed.
   *
   * @default true
   * @name DataStore#unlinkOnDestroy
   * @since 3.0.0
   * @type {boolean}
   */
  unlinkOnDestroy: true
}

/**
 * The `DataStore` class is an extension of {@link SimpleStore}. Not only does
 * `DataStore` manage mappers and store data in collections, it uses the
 * {@link LinkedCollection} class to link related records together in memory.
 *
 * ```javascript
 * import {DataStore} from 'js-data'
 * ```
 *
 * @example
 * import {DataStore} from 'js-data'
 * import HttpAdapter from 'js-data-http'
 * const store = new DataStore()
 *
 * // DataStore#defineMapper returns a direct reference to the newly created
 * // Mapper.
 * const UserMapper = store.defineMapper('user')
 *
 * // DataStore#as returns the store scoped to a particular Mapper.
 * const UserStore = store.as('user')
 *
 * // Call "find" on "UserMapper" (Stateless ORM)
 * UserMapper.find(1).then((user) => {
 *   // retrieved a "user" record via the http adapter, but that's it
 *
 *   // Call "find" on "store" targeting "user" (Stateful DataStore)
 *   return store.find('user', 1) // same as "UserStore.find(1)"
 * }).then((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)
 *   console.log(user === cachedUser) // true
 * })
 *
 * @class DataStore
 * @extends SimpleStore
 * @param {Object} [opts] Configuration options. See {@link SimpleStore}.
 * @param {boolean} [opts.collectionClass={@link LinkedCollection}] See {@link DataStore#collectionClass}.
 * @param {boolean} [opts.debug=false] See {@link Component#debug}.
 * @param {boolean} [opts.unlinkOnDestroy=true] See {@link DataStore#unlinkOnDestroy}.
 * @param {boolean|Function} [opts.usePendingFind=true] See {@link DataStore#usePendingFind}.
 * @param {boolean|Function} [opts.usePendingFindAll=true] See {@link DataStore#usePendingFindAll}.
 * @returns {DataStore}
 * @see SimpleStore
 * @since 3.0.0
 * @tutorial ["http://www.js-data.io/v3.0/docs/components-of-jsdata#datastore","Components of JSData: DataStore"]
 * @tutorial ["http://www.js-data.io/v3.0/docs/working-with-the-datastore","Working with the DataStore"]
 * @tutorial ["http://www.js-data.io/v3.0/docs/jsdata-and-the-browser","Notes on using JSData in the Browser"]
 */
function DataStore (opts) {
  utils.classCallCheck(this, DataStore)

  opts || (opts = {})
  // Fill in any missing options with the defaults
  utils.fillIn(opts, DATASTORE_DEFAULTS)
  opts.collectionClass || (opts.collectionClass = LinkedCollection)
  SimpleStore.call(this, opts)
}

const props = {
  constructor: DataStore,

  defineMapper (name, opts) {
    // Complexity of this method is beyond simply using => functions to bind context
    const self = this
    const mapper = SimpleStore.prototype.defineMapper.call(self, name, opts)
    const idAttribute = mapper.idAttribute
    const collection = this.getCollection(name)

    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 updateOpts = { index: foreignKey }
      let descriptor

      const getter = function () { return this._get(path) }

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

        descriptor = {
          get: getter,
          // e.g. profile.user = someUser
          // or comment.post = somePost
          set (record) {
            // e.g. const otherUser = profile.user
            const currentParent = this._get(path)
            // e.g. profile.user === someUser
            if (record === currentParent) {
              return currentParent
            }
            const id = utils.get(this, idAttribute)
            const inverseDef = def.getInverse(mapper)

            // e.g. profile.user !== someUser
            // or comment.post !== somePost
            if (currentParent && inverseDef) {
              this.removeInverseRelation(currentParent, id, inverseDef, idAttribute)
            }
            if (record) {
              // e.g. profile.user = someUser
              const relatedIdAttribute = def.getRelation().idAttribute
              const relatedId = utils.get(record, relatedIdAttribute)

              // Prefer store record
              if (relatedId !== undefined && this._get('$')) {
                record = self.get(relation, relatedId) || record
              }

              // Set locals
              // e.g. profile.user = someUser
              // or comment.post = somePost
              safeSetLink(this, localField, record)
              safeSetProp(this, foreignKey, relatedId)
              collection.updateIndex(this, updateOpts)

              if (inverseDef) {
                this.setupInverseRelation(record, id, inverseDef, idAttribute)
              }
            } else {
              // Unset in-memory link only
              // e.g. profile.user = undefined
              // or comment.post = undefined
              safeSetLink(this, localField, undefined)
            }
            return record
          }
        }

        let foreignKeyDescriptor = Object.getOwnPropertyDescriptor(mapper.recordClass.prototype, foreignKey)
        if (!foreignKeyDescriptor) {
          foreignKeyDescriptor = {
            enumerable: true
          }
        }
        const originalGet = foreignKeyDescriptor.get
        foreignKeyDescriptor.get = function () {
          if (originalGet) {
            return originalGet.call(this)
          }
          return this._get(`props.${foreignKey}`)
        }
        const originalSet = foreignKeyDescriptor.set
        foreignKeyDescriptor.set = function (value) {
          if (originalSet) {
            originalSet.call(this, value)
          }
          const currentParent = utils.get(this, localField)
          const id = utils.get(this, idAttribute)
          const inverseDef = def.getInverse(mapper)
          const currentParentId = currentParent ? utils.get(currentParent, def.getRelation().idAttribute) : undefined

          if (currentParent && currentParentId !== undefined && currentParentId !== value) {
            if (inverseDef.type === hasOneType) {
              safeSetLink(currentParent, inverseDef.localField, undefined)
            } else if (inverseDef.type === hasManyType) {
              const children = utils.get(currentParent, inverseDef.localField)
              if (id === undefined) {
                utils.remove(children, (child) => child === this)
              } else {
                utils.remove(children, (child) => child === this || id === utils.get(child, idAttribute))
              }
            }
          }

          safeSetProp(this, foreignKey, value)
          collection.updateIndex(this, updateOpts)

          if ((value === undefined || value === null)) {
            if (currentParentId !== undefined) {
              // Unset locals
              utils.set(this, localField, undefined)
            }
          } else if (this._get('$')) {
            const storeRecord = self.get(relation, value)
            if (storeRecord) {
              utils.set(this, localField, storeRecord)
            }
          }
        }
        Object.defineProperty(mapper.recordClass.prototype, foreignKey, foreignKeyDescriptor)
      } 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 () {
            let current = getter.call(this)
            if (!current) {
              this._set(path, [])
            }
            return getter.call(this)
          },
          // e.g. post.comments = someComments
          // or user.groups = someGroups
          // or group.users = someUsers
          set (records) {
            if (records && !utils.isArray(records)) {
              records = [records]
            }
            const id = utils.get(this, idAttribute)
            const relatedIdAttribute = def.getRelation().idAttribute
            const inverseDef = def.getInverse(mapper)
            const inverseLocalField = inverseDef.localField
            const current = this._get(path) || []
            const toLink = []
            const toLinkIds = {}

            if (records) {
              records.forEach((record) => {
                // e.g. comment.id
                const relatedId = utils.get(record, relatedIdAttribute)
                const currentParent = utils.get(record, inverseLocalField)
                if (currentParent && currentParent !== this) {
                  const currentChildrenOfParent = utils.get(currentParent, localField)
                  // e.g. somePost.comments.remove(comment)
                  if (relatedId === undefined) {
                    utils.remove(currentChildrenOfParent, (child) => child === record)
                  } else {
                    utils.remove(currentChildrenOfParent, (child) => child === record || relatedId === utils.get(child, relatedIdAttribute))
                  }
                }
                if (relatedId !== undefined) {
                  if (this._get('$')) {
                    // Prefer store record
                    record = self.get(relation, relatedId) || record
                  }
                  // e.g. toLinkIds[comment.id] = comment
                  toLinkIds[relatedId] = record
                }
                toLink.push(record)
              })
            }

            // e.g. post.comments = someComments
            if (foreignKey) {
              current.forEach((record) => {
                // e.g. comment.id
                const relatedId = utils.get(record, relatedIdAttribute)
                if ((relatedId === undefined && toLink.indexOf(record) === -1) || (relatedId !== undefined && !(relatedId in toLinkIds))) {
                  // Update (unset) inverse relation
                  if (records) {
                    // e.g. comment.post_id = undefined
                    safeSetProp(record, foreignKey, undefined)
                    // e.g. CommentCollection.updateIndex(comment, { index: 'post_id' })
                    self.getCollection(relation).updateIndex(record, updateOpts)
                  }
                  // e.g. comment.post = undefined
                  safeSetLink(record, inverseLocalField, undefined)
                }
              })
              toLink.forEach((record) => {
                // Update (set) inverse relation
                // e.g. comment.post_id = post.id
                safeSetProp(record, foreignKey, id)
                // e.g. CommentCollection.updateIndex(comment, { index: 'post_id' })
                self.getCollection(relation).updateIndex(record, updateOpts)
                // e.g. comment.post = post
                safeSetLink(record, inverseLocalField, this)
              })
            } else if (localKeys) {
              // Update locals
              // e.g. group.users = someUsers
              // Update (set) inverse relation
              const ids = toLink.map((child) => utils.get(child, relatedIdAttribute)).filter((id) => id !== undefined)
              // e.g. group.user_ids = [1,2,3,...]
              utils.set(this, localKeys, ids)
              // Update (unset) inverse relation
              if (inverseDef.foreignKeys) {
                current.forEach((child) => {
                  const relatedId = utils.get(child, relatedIdAttribute)
                  if ((relatedId === undefined && toLink.indexOf(child) === -1) || (relatedId !== undefined && !(relatedId in toLinkIds))) {
                    // Update inverse relation
                    // safeSetLink(child, inverseLocalField, undefined)
                    const parents = utils.get(child, inverseLocalField) || []
                    // e.g. someUser.groups.remove(group)
                    if (id === undefined) {
                      utils.remove(parents, (parent) => parent === this)
                    } else {
                      utils.remove(parents, (parent) => parent === this || id === utils.get(parent, idAttribute))
                    }
                  }
                })
                toLink.forEach((child) => {
                  // Update (set) inverse relation
                  const parents = utils.get(child, inverseLocalField)
                  // e.g. someUser.groups.push(group)
                  if (id === undefined) {
                    utils.noDupeAdd(parents, this, (parent) => parent === this)
                  } else {
                    utils.noDupeAdd(parents, this, (parent) => parent === this || id === utils.get(parent, idAttribute))
                  }
                })
              }
            } else if (foreignKeys) {
              // e.g. user.groups = someGroups
              // Update (unset) inverse relation
              current.forEach((parent) => {
                const ids = utils.get(parent, foreignKeys) || []
                // e.g. someGroup.user_ids.remove(user.id)
                utils.remove(ids, (_key) => id === _key)
                const children = utils.get(parent, inverseLocalField)
                // e.g. someGroup.users.remove(user)
                if (id === undefined) {
                  utils.remove(children, (child) => child === this)
                } else {
                  utils.remove(children, (child) => child === this || id === utils.get(child, idAttribute))
                }
              })
              // Update (set) inverse relation
              toLink.forEach((parent) => {
                const ids = utils.get(parent, foreignKeys) || []
                utils.noDupeAdd(ids, id, (_key) => id === _key)
                const children = utils.get(parent, inverseLocalField)
                if (id === undefined) {
                  utils.noDupeAdd(children, this, (child) => child === this)
                } else {
                  utils.noDupeAdd(children, this, (child) => child === this || id === utils.get(child, idAttribute))
                }
              })
            }

            this._set(path, toLink)
            return toLink
          }
        }
      } else if (type === hasOneType) {
        // 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: getter,
          // e.g. user.profile = someProfile
          set (record) {
            const current = this._get(path)
            if (record === current) {
              return current
            }
            const inverseLocalField = def.getInverse(mapper).localField
            // Update (unset) inverse relation
            if (current) {
              safeSetProp(current, foreignKey, undefined)
              self.getCollection(relation).updateIndex(current, updateOpts)
              safeSetLink(current, inverseLocalField, undefined)
            }
            if (record) {
              const relatedId = utils.get(record, def.getRelation().idAttribute)
              // Prefer store record
              if (relatedId !== undefined) {
                record = self.get(relation, relatedId) || record
              }

              // Set locals
              safeSetLink(this, localField, record)

              // Update (set) inverse relation
              safeSetProp(record, foreignKey, utils.get(this, idAttribute))
              self.getCollection(relation).updateIndex(record, updateOpts)
              safeSetLink(record, inverseLocalField, this)
            } else {
              // Unset locals
              safeSetLink(this, localField, undefined)
            }
            return record
          }
        }
      }

      if (descriptor) {
        descriptor.enumerable = def.enumerable === undefined ? false : 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
  },

  destroy (name, id, opts) {
    opts || (opts = {})
    return SimpleStore.prototype.destroy.call(this, name, id, opts).then((result) => {
      let record
      if (opts.raw) {
        record = result.data
      } else {
        record = result
      }

      if (record && this.unlinkOnDestroy) {
        const _opts = utils.plainCopy(opts)
        _opts.withAll = true
        utils.forEachRelation(this.getMapper(name), _opts, (def) => {
          utils.set(record, def.localField, undefined)
        })
      }
      return result
    })
  },

  destroyAll (name, query, opts) {
    opts || (opts = {})
    return SimpleStore.prototype.destroyAll.call(this, name, query, opts).then((result) => {
      let records
      if (opts.raw) {
        records = result.data
      } else {
        records = result
      }

      if (records && records.length && this.unlinkOnDestroy) {
        const _opts = utils.plainCopy(opts)
        _opts.withAll = true
        utils.forEachRelation(this.getMapper(name), _opts, (def) => {
          records.forEach((record) => {
            utils.set(record, def.localField, undefined)
          })
        })
      }
      return result
    })
  }
}

export default SimpleStore.extend(props)

/**
 * Create a subclass of this DataStore:
 * @example <caption>DataStore.extend</caption>
 * // Normally you would do: import {DataStore} from 'js-data'
 * const JSData = require('js-data@3.0.0-rc.4')
 * const {DataStore} = JSData
 * console.log('Using JSData v' + JSData.version.full)
 *
 * // Extend the class using ES2015 class syntax.
 * class CustomDataStoreClass extends DataStore {
 *   foo () { return 'bar' }
 *   static beep () { return 'boop' }
 * }
 * const customDataStore = new CustomDataStoreClass()
 * console.log(customDataStore.foo())
 * console.log(CustomDataStoreClass.beep())
 *
 * // Extend the class using alternate method.
 * const OtherDataStoreClass = DataStore.extend({
 *   foo () { return 'bar' }
 * }, {
 *   beep () { return 'boop' }
 * })
 * const otherDataStore = new OtherDataStoreClass()
 * console.log(otherDataStore.foo())
 * console.log(OtherDataStoreClass.beep())
 *
 * // Extend the class, providing a custom constructor.
 * function AnotherDataStoreClass () {
 *   DataStore.call(this)
 *   this.created_at = new Date().getTime()
 * }
 * DataStore.extend({
 *   constructor: AnotherDataStoreClass,
 *   foo () { return 'bar' }
 * }, {
 *   beep () { return 'boop' }
 * })
 * const anotherDataStore = new AnotherDataStoreClass()
 * console.log(anotherDataStore.created_at)
 * console.log(anotherDataStore.foo())
 * console.log(AnotherDataStoreClass.beep())
 *
 * @method DataStore.extend
 * @param {Object} [props={}] Properties to add to the prototype of the
 * subclass.
 * @param {Object} [props.constructor] Provide a custom constructor function
 * to be used as the subclass itself.
 * @param {Object} [classProps={}] Static properties to add to the subclass.
 * @returns {Constructor} Subclass of this DataStore class.
 * @since 3.0.0
 */