Source: Record.js

import {
  _,
  addHiddenPropsToTarget,
  classCallCheck,
  copy,
  eventify,
  extend,
  fillIn,
  forOwn,
  get,
  isFunction,
  isObject,
  isSorN,
  isString,
  resolve,
  set,
  unset
} from './utils'

/**
 * js-data's Record class.
 *
 * ```javascript
 * import {Record} from 'js-data'
 * ```
 *
 * @class Record
 * @param {Object} [props] The initial properties of the new Record instance.
 * @param {Object} [opts] Configuration options.
 * @param {boolean} [opts.noValidate=false] Whether to skip validation on the
 * initial properties.
 */
export default function Record (props, opts) {
  const self = this
  classCallCheck(self, Record)

  props || (props = {})
  opts || (opts = {})
  const _props = {}
  Object.defineProperties(self, {
    _get: {
      value (key) {
        return get(_props, key)
      }
    },
    _set: {
      value (key, value) {
        return set(_props, key, value)
      }
    },
    _unset: {
      value (key) {
        return unset(_props, key)
      }
    }
  })
  const _set = self._set
  // TODO: Optimize these strings
  _set('creating', true)
  if (opts.noValidate) {
    _set('noValidate', true)
  }
  fillIn(self, props)
  _set('creating') // unset
  _set('changes', {})
  _set('noValidate') // unset
  _set('previous', copy(props))
}

/**
 * Create a Record subclass.
 *
 * ```javascript
 * var MyRecord = Record.extend({
 *   foo: function () { return 'bar' }
 * })
 * var record = new MyRecord()
 * record.foo() // "bar"
 * ```
 *
 * @name Record.extend
 * @method
 * @param {Object} [props={}] Properties to add to the prototype of the
 * subclass.
 * @param {Object} [classProps={}] Static properties to add to the subclass.
 * @return {Function} Subclass of Record.
 */
Record.extend = extend

addHiddenPropsToTarget(Record.prototype, {
  /**
   * TODO
   *
   * @name Record#_mapper
   * @method
   * @ignore
   */
  _mapper () {
    if (!this.constructor.Mapper) {
      throw new Error('This RecordClass has no Mapper!')
    }
    return this.constructor.Mapper
  },

  /**
   * Return the value at the given path for this instance.
   *
   * @name Record#get
   * @method
   * @param {string} key - Path of value to retrieve.
   * @return {*} Value at path.
   */
  get: function (key) {
    return get(this, key)
  },

  /**
   * Set the value for a given key, or the values for the given keys if "key" is
   * an object.
   *
   * @name Record#set
   * @method
   * @param {(string|Object)} key - Key to set or hash of key-value pairs to set.
   * @param {*} [value] - Value to set for the given key.
   * @param {Object} [opts] - Optional configuration.
   * @param {boolean} [opts.silent=false] - Whether to trigger change events.
   */
  set: function (key, value, opts) {
    const self = this
    if (isObject(key)) {
      opts = value
    }
    opts || (opts = {})
    if (opts.silent) {
      self._set('silent', true)
    }
    set(self, key, value)
    if (!self._get('eventId')) {
      self._set('silent') // unset
    }
  },

  /**
   * Unset the value for a given key.
   *
   * @name Record#unset
   * @method
   * @param {string} key - Key to unset.
   * @param {Object} [opts] - Optional configuration.
   * @param {boolean} [opts.silent=false] - Whether to trigger change events.
   */
  unset (key, opts) {
    this.set(key, undefined, opts)
  },

  /**
   * TODO
   *
   * @name Record#hashCode
   * @method
   */
  hashCode () {
    const self = this
    return get(self, self._mapper().idAttribute)
  },

  /**
   * TODO
   *
   * @name Record#changes
   * @method
   * @param {string} [key] TODO
   */
  changes (key) {
    const self = this
    if (key) {
      return self._get(`changes.${key}`)
    }
    return self._get('changes')
  },

  /**
   * TODO
   *
   * @name Record#hasChanges
   * @method
   */
  hasChanges () {
    return !!(this._get('changed') || []).length
  },

  /**
   * TODO
   *
   * @name Record#commit
   * @method
   */
  commit () {
    const self = this
    self._set('changed') // unset
    self._set('changes', {})
    self._set('previous', copy(self))
    return self
  },

  /**
   * TODO
   *
   * @name Record#previous
   * @method
   * @param {string} [key] TODO
   */
  previous (key) {
    const self = this
    if (key) {
      return self._get(`previous.${key}`)
    }
    return self._get('previous')
  },

  /**
   * TODO
   *
   * @name Record#revert
   * @method
   * @param {Object} [opts] Configuration options.
   */
  revert (opts) {
    const self = this
    const previous = self._get('previous') || {}
    opts || (opts = {})
    opts.preserve || (opts.preserve = [])
    forOwn(self, (value, key) => {
      if (key !== self._mapper().idAttribute && !previous.hasOwnProperty(key) && self.hasOwnProperty(key) && opts.preserve.indexOf(key) === -1) {
        delete self[key]
      }
    })
    forOwn(previous, (value, key) => {
      if (opts.preserve.indexOf(key) === -1) {
        self[key] = value
      }
    })
    self.commit()
    return self
  },

  /**
   * TODO
   *
   * @name Record#schema
   * @method
   * @param {string} [key] TODO
   */
  schema (key) {
    let _schema = this._mapper().schema
    return key ? _schema[key] : _schema
  },

  // validate (obj, value) {
  //   let errors = []
  //   let _schema = this.schema()
  //   if (!obj) {
  //     obj = this
  //   } else if (utils.isString(obj)) {
  //     const prop = _schema[obj]
  //     if (prop) {
  //       errors = validate.validate(prop, value) || []
  //     }
  //   } else {
  //     utils.forOwn(_schema, function (prop, key) {
  //       errors = errors.concat(validate.validate(prop, utils.get(obj, key)) || [])
  //     })
  //   }
  //   return errors.length ? errors : undefined
  // },

  /**
   * TODO
   *
   * @name Record#create
   * @method
   * @param {Object} [opts] Configuration options. See {@link Mapper#create}.
   */
  create (opts) {
    return this._mapper().create(this, opts)
  },

  /**
   * TODO
   *
   * @name Record#beforeSave
   * @method
   * @param {Object} opts TODO
   */
  beforeSave () {},

  /**
   * TODO
   *
   * @name Record#save
   * @method
   * @param {Object} [opts] Configuration options. See {@link Mapper#create}.
   */
  save (opts) {
    let op, adapter
    const self = this
    const Mapper = self._mapper()

    // Default values for arguments
    opts || (opts = {})

    // Fill in "opts" with the Model's configuration
    _(self, opts)
    adapter = opts.adapter = self.getAdapterName(opts)

    // beforeSave lifecycle hook
    op = opts.op = 'beforeSave'
    return resolve(self[op](opts)).then(function () {
      // Now delegate to the adapter
      op = opts.op = 'save'
      Mapper.dbg(op, self, opts)
      return self.getAdapter(adapter)[op](Mapper, self, opts)
    }).then(function (data) {
      // afterSave lifecycle hook
      op = opts.op = 'afterSave'
      return resolve(self[op](data, opts)).then(function (_data) {
        // Allow for re-assignment from lifecycle hook
        data = _data || data
        if (opts.raw) {
          self.set(data.data)
          data.data = self
        } else {
          self.set(data)
        }
        return Mapper.end(data, opts)
      })
    })
  },

  /**
   * TODO
   *
   * @name Record#afterSave
   * @method
   * @param {Object} opts TODO
   */
  afterSave () {},

  /**
   * TODO
   *
   * @name Record#beforeLoadRelations
   * @method
   * @param {string[]} relations TODO
   * @param {Object} opts TODO
   */
  beforeLoadRelations () {},

  /**
   * TODO
   *
   * @name Record#loadRelations
   * @method
   * @param {string[]} [relations] TODO
   * @param {Object} [opts] TODO
   */
  loadRelations (relations, opts) {
    let op
    const self = this
    const Mapper = self._mapper()
    const relationList = Mapper.relationList || []

    // Default values for arguments
    relations || (relations = [])
    opts || (opts = {})

    // Fill in "opts" with the Model's configuration
    _(Mapper, opts)
    opts.adapter = Mapper.getAdapterName(opts)

    // beforeLoadRelations lifecycle hook
    op = opts.op = 'beforeLoadRelations'
    return resolve(self[op](relations, opts)).then(function () {
      if (isString(relations)) {
        relations = [relations]
      }
      // Now delegate to the adapter
      op = opts.op = 'loadRelations'
      Mapper.dbg(op, self, relations, opts)
      return Promise.all(relationList.map(function (def) {
        if (isFunction(def.load)) {
          return def.load(Mapper, def, self, opts)
        }
        let task
        if (def.type === 'hasMany' && def.foreignKey) {
          // hasMany
          task = def.getRelation().findAll({
            [def.foreignKey]: get(self, Mapper.idAttribute)
          }, opts)
        } else if (def.foreignKey) {
          // belongsTo or hasOne
          const key = get(self, def.foreignKey)
          if (isSorN(key)) {
            task = def.getRelation().find(key, opts)
          }
        } else if (def.localKeys) {
          // hasMany
          task = def.getRelation().findAll({
            [def.getRelation().idAttribute]: {
              'in': get(self, def.localKeys)
            }
          }, opts)
        } else if (def.foreignKeys) {
          // hasMany
          task = def.getRelation().findAll({
            [def.getRelation().idAttribute]: {
              'contains': get(self, Mapper.idAttribute)
            }
          }, opts)
        }
        if (task) {
          task = task.then(function (data) {
            if (opts.raw) {
              data = data.data
            }
            set(self, def.localField, def.type === 'hasOne' ? (data.length ? data[0] : undefined) : data)
          })
        }
        return task
      }))
    }).then(function () {
      // afterLoadRelations lifecycle hook
      op = opts.op = 'afterLoadRelations'
      return resolve(self[op](relations, opts)).then(function () {
        return self
      })
    })
  },

  /**
   * TODO
   *
   * @name Record#afterLoadRelations
   * @method
   * @param {string[]} relations TODO
   * @param {Object} opts TODO
   */
  afterLoadRelations () {},

  /**
   * TODO
   *
   * @name Record#destroy
   * @method
   * @param {Object} [opts] Configuration options. @see {@link Model.destroy}.
   */
  destroy (opts) {
    // TODO: move actual destroy logic here
    const Mapper = this._mapper()
    return Mapper.destroy(get(this, Mapper.idAttribute), opts)
  },

  // TODO: move logic for single-item async operations onto the instance.

  /**
   * Return a plain object representation of this record. If the class from
   * which this record was created has a mapper, then {@link Mapper#toJSON} will
   * be called instead.
   *
   * @name Record#toJSON
   * @method
   * @param {Object} [opts] Configuration options.
   * @param {string[]} [opts.with] Array of relation names or relation fields
   * to include in the representation. Only available as an option if the class
   * from which this record was created has a mapper.
   * @return {Object} Plain object representation of this record.
   */
  toJSON (opts) {
    const mapper = this.constructor.Mapper
    if (mapper) {
      return mapper.toJSON(this, opts)
    } else {
      const json = {}
      forOwn(this, function (prop, key) {
        json[key] = copy(prop)
      })
      return json
    }
  }
})

/**
 * Register a new event listener on this Record.
 *
 * @name Record#on
 * @method
 */

/**
 * Remove an event listener from this Record.
 *
 * @name Record#off
 * @method
 */

/**
 * Trigger an event on this Record.
 *
 * @name Record#emit
 * @method
 * @param {string} event Name of event to emit.
 */

/**
 * Allow records to emit events.
 *
 * An record's registered listeners are stored in the record's private data.
 */
eventify(
  Record.prototype,
  function () {
    return this._get('events')
  },
  function (value) {
    this._set('events', value)
  }
)