Source: entities/Entity.js

/*
* @preserve This file is part of "lassi".
*    Copyright 2009-2014, arNuméral
*    Author : Yoran Brault
*    eMail  : yoran.brault@arnumeral.fr
*    Site   : http://arnumeral.fr
*
* "lassi" is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* "lassi" is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with "lassi"; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/

'use strict'

const _ = require('lodash')
const flow = require('an-flow')
const ObjectID = require('mongodb').ObjectID
// const log = require('an-log')('Entity');
const { castToType } = require('./internals')

/**
 * Construction d'une entité. Passez par la méthode {@link Component#entity} pour créer une entité.
 * @constructor
 * @param {Object} settings
 */
class Entity {
  // eslint-disable-next-line no-useless-constructor
  constructor () {
    // Warning: ne rien implémenter ici, car ce constructeur n'est pas
    // appelé dans EntityDefinition#create
    throw new Error('Une entité n\'est jamais instanciée directement. Utiliser EntityDefinition#create')
  }

  setDefinition (entityDefinition) {
    Object.defineProperty(this, 'definition', { value: entityDefinition })
  }

  /**
   * Répond true si l'instance de cette Entity n'a jamais été insérée en base de donnée
   * @return {boolean} true si l'instance n'a jamais été sauvegardée
   */
  isNew () {
    return !this.oid
  }

  /**
   * Répond true si l'instance de cette Entity est "soft deleted"
   * @return {boolean}
   */
  isDeleted () {
    return !!this.__deletedAt
  }

  /**
   * Lance une validation de l'entity. Par défaut on parcourt toutes les validations :
   * validate(), validateJsonSchema() et validateOnChange()
   * @param {errorCallback} cb rappelée avec la première erreur de validation (ou rien si c'est valide)
   * @param {object} [options]
   * @param {boolean} [options.schema=true] passer false pour ne pas tester le schéma éventuel
   * @param {boolean} [options.onlyChangedAttributes=false] passer true pour ne tester que les attributs modifiés
   */
  isValid (cb, { schema = true, onlyChangedAttributes = false } = {}) {
    const validators = [].concat(

      // Json-schema validation
      schema ? [function (cb) { this.definition._validateEntityWithSchema(this, cb) }] : [],

      // validateOnChange() validation.
      // this.definition._toValidateOnChange est de la forme: {
      //   attributeName: [validateFunction1, validateFunction2]
      // }
      // On prend donc toutes les fonctions correspondant aux attributs ayant changés
      // puis on applique uniq() car une fonction peut être déclarée sur plusieurs changements d'attributs.
      _.uniq(_.flatten(_.values(
        onlyChangedAttributes
          ? _.pick(this.definition._toValidateOnChange, this.changedAttributes())
          : this.definition._toValidateOnChange
      ))),

      // et on ajoute le tableau de validateurs passés via entityDefinition.validate(validator)
      this.definition._toValidate
    )

    const entity = this

    // et on applique les validateurs en série
    flow(validators).seqEach(function (fn) {
      fn.call(entity, this)
    }).done(cb)
  }

  /**
   * Retourne une shallow copy de l'entity en filtrant certaines de ses données :
   * - les attributs de 1er niveau ayant un nom commençant par "_"
   * - les attributs ayant un nom commençant par $ (récursion sur les plain object seulement, pas les Date RegExp & co)
   * - les attributs étant function
   * - les attributs ayant des valeurs null, undefined ou NaN (en profondeur)
   * @return {Object}
   */
  values () {
    const isPlainObject = (o) => o && typeof o === 'object' && Object.prototype.toString.call(o) === '[object Object]'

    const cleanArray = (a) => a.map(elt => {
      if (isPlainObject(elt)) return copyCleanProps(elt, {})
      if (Array.isArray(elt) && elt.length) return cleanArray(elt)
      return elt
    })

    const copyCleanProps = (obj, dest, isFirstLevel = false) => {
      Object.keys(obj).forEach(key => {
        const v = obj[key]
        // au 1er niveau on vire les propriétés préfixées par _
        if (isFirstLevel && (key[0] === '_')) return
        // à tous les niveaux on vire null, undefined, function et préfixe $
        if (v === null || v === undefined || typeof v === 'function' || key[0] === '$') return
        // on fait de la récursion sur les objets qui n'ont pas d'autre constructeur que Object
        // (ni Regexp ni Date, les objets définis avec un constructeur classique passent ce filtre)
        if (isPlainObject(v)) {
          dest[key] = {}
          copyCleanProps(v, dest[key])

        // et chaque élément de tableau (on vire pas null et undefined)
        } else if (Array.isArray(v) && v.length) {
          dest[key] = cleanArray(v)

        // pour les autres on prend la valeur telle quelle
        } else {
          dest[key] = v
        }
      })
      return dest
    }

    return copyCleanProps(this, {}, true)
  }

  /**
   * Construits les index d'après l'entity
   * @private
   * @returns {Object} avec une propriété par index (elle existe toujours mais sa valeur peut être undefined, ce qui se traduira par null dans le document mongo)
   */
  buildIndexes () {
    const def = this.definition
    const entity = this
    const indexes = {}

    // pas besoin de traiter les BUILT_IN_INDEXES, ils sont gérés directement dans le store
    _.forEach(def.indexes, ({ callback, fieldType, useData, indexName }) => {
      if (useData) return // on utilise directement un index sur _data
      // la cb d'index peut planter, on veut récupérer le message pour le renvoyer avec plus d'infos
      try {
        // valeurs retournées par la fct d'indexation si y'en a une (inclus normalizer s'il existe)
        const value = callback ? callback.call(entity) : entity[indexName]

        if (value === undefined || value === null) {
          // https://docs.mongodb.com/manual/core/index-sparse/
          // En résumé
          // - non-sparse : tous les documents sont indexés :
          //   => si la propriété n'existe pas dans l'objet elle sera indexée quand même avec une valeur null
          //   => si la propriété vaut undefined elle sera indexée avec null
          // - sparse : seulement les documents ayant la propriété (même null|undefined) sont indexés (undefined indexé avec la valeur null)
          //
          // Afin d'avoir un comportement homogène, buildIndexes va harmoniser les 3 cas
          // - prop absente
          // - prop avec valeur undefined
          // - prop avec valeur null
          // 1) si index non-sparse, on ne retourne rien et on laisse faire mongo,
          //    l'index ne sera pas dans le doc mongo mais ça revient au même qu'un null,
          //    dans les 3 cas isNull remontera l'entity.
          // 2) si index sparse, on supprime l'index pour null|undefined
          //    => dans les 3 cas le doc mongo n'est pas indexé
          //    => isNull remonte les 3
          return
        }

        const castAndCheckNaN = (v) => {
          // le test précédent ne gère pas les array, et dans un array on garde la valeur originale
          // donc pour l'index ça se traduit par null (isNull remontera les entity dont la valeur contient null ou undefined)
          // c'est pas très logique (on pourrait s'attendre à ce que isNull remonte les entities dont la valeur est un tableau vide)
          // mais on veut le même comportement avec et sans useData (et avec mongo indexe l'objet [])
          if (v === undefined || v === null) return null
          if (fieldType) v = castToType(v, fieldType)
          if (typeof v === 'number' && Number.isNaN(v)) throw Error(`${indexName} contient NaN`)
          return v
        }

        // affectation après cast dans le type indiqué (si y'en a un)
        if (Array.isArray(value)) indexes[indexName] = value.map(castAndCheckNaN)
        else indexes[indexName] = castAndCheckNaN(value)
      } catch (error) {
        error.message = `Pb sur l’index ${indexName} de l’entity ${def.name} oid ${entity.oid}, ${error.message}`
        throw error
      }
    })

    return indexes
  }

  /**
   * Idem this[att] sauf si att vaut isDeleted (retourne alors le booléen)
   * @private
   * @param {string} att
   * @return {*}
   */
  getAttributeValue (att) {
    if (att === 'isDeleted') return this.isDeleted()
    return this[att]
  }

  /**
   * Appelé après un load bdd pour stocker les valeurs des attributs suivis
   * @private
   */
  onLoad () {
    if (this.definition._onLoad) this.definition._onLoad.call(this)
    // Keep track of the entity state when loaded, so that we can compare when storing
    this.$loadState = {}

    Object.keys(this.definition._trackedAttributes).forEach((attribute) => {
      this.$loadState[attribute] = this.getAttributeValue(attribute)
    })
  }

  /**
   * Retourne la liste des attributs suivis qui ont changés depuis la sortie de la bdd
   * @return {string[]}
   */
  changedAttributes () {
    return Object.keys(this.definition._trackedAttributes).filter((att) => this.attributeHasChanged(att))
  }

  /**
   * Retourne true si l'attribut suivi a changé
   * @throws si y'a pas eu de EntityDefinitiontrackAttribute(attribute) sur cette Entity
   * @param {string} attribute
   * @return {boolean} true si l'attribut a changé (toujours le cas sur une création)
   */
  attributeHasChanged (attribute) {
    // Une nouvelle entité non sauvegardée n'a pas de "loadState", mais
    // on considère que tous ses attributs ont changés
    if (!this.$loadState) return true
    return this.attributeWas(attribute) !== this.getAttributeValue(attribute)
  }

  /**
   * Retourne la valeur de l'attribut au dernier chargement depuis la base
   * @throws si y'a pas eu de EntityDefinition.trackAttribute(attribute) sur cette Entity pour cet attribut
   * @param {string} attribute
   * @return {*} null si l'entity ne sort pas de la db
   */
  attributeWas (attribute) {
    if (!this.definition._trackedAttributes[attribute]) {
      throw new Error(`L'attribut ${attribute} n'est pas suivi`)
    }
    // Une nouvelle entité non sauvegardée n'a pas de "loadState"
    if (!this.$loadState) return null

    return this.$loadState[attribute]
  }

  /**
   * Applique le beforeStore s'il y en a un puis vérifie la validité
   * @private
   * @param {Entity~entityCallback} cb
   * @param {Object} [storeOptions]
   * @param {boolean} [storeOptions.skipValidation=false]
   */
  beforeStore (cb, storeOptions) {
    const entity = this
    const def = this.definition
    flow().seq(function () {
      if (def._beforeStore) def._beforeStore.call(entity, this)
      else this()
    }).seq(function () {
      if (def._skipValidation || storeOptions.skipValidation) cb()
      else entity.isValid(cb, { onlyChangedAttributes: true })
    }).catch(cb)
  }

  /**
   * Retourne l'objet db de mongo. À réserver à des cas très particuliers,
   * à priori il faut utiliser les méthodes de EntityQuery pour toutes vos requêtes
   * @return {Db}
   */
  db () {
    return this.definition.entities.db
  }

  /**
   * @callback Entity~entityCallback
   * @param {Error} error
   * @param {Entity} entity
   */
  /**
   * Stockage d'une instance d'entité
   * @param {Object} [options]
   * @param {boolean} [options.skipValidation]
   * @param {Entity~entityCallback}
   */
  store (options, callback) {
    const entity = this
    const def = this.definition
    const isNew = !entity.oid

    if (_.isFunction(options)) {
      callback = options
      options = undefined
    }

    options = Object.assign({}, options, { object: true, index: true })

    callback = callback || function () {}

    let document
    flow().seq(function () {
      entity.beforeStore(this, options)
    }).seq(function () {
      // on génère un oid sur les créations
      if (isNew) entity.oid = ObjectID().toString()
      // les index
      document = entity.buildIndexes()
      if (entity.__deletedAt) document.__deletedAt = entity.__deletedAt
      document._id = entity.oid
      document._data = entity.values()
      // {w:1} est le write concern par défaut, mais on le rend explicite (on veut que la callback
      // soit rappelée une fois que l'écriture est effective sur le 1er master)
      // @see https://docs.mongodb.com/manual/reference/write-concern/
      if (isNew) def.getCollection().insertOne(document, { w: 1 }, this)
      // upsert devrait être omis ici car l'objet doit exister en base,
      // c'est au cas où qqun l'aurait supprimé depuis sa lecture
      else def.getCollection().replaceOne({ _id: document._id }, document, { upsert: true, w: 1 }, this)
    }).seq(function () {
      // suivant insert / replace
      // on récupère un http://mongodb.github.io/node-mongodb-native/2.2/api/Collection.html#~insertOneWriteOpResult
      // ou un http://mongodb.github.io/node-mongodb-native/2.2/api/Collection.html#~updateWriteOpResult
      if (def._afterStore) {
        // faudrait appeler _afterStore avec l'entité telle qu'elle serait récupérée de la base,
        // on l'a pas directement sous la main mais entity devrait être identique en tout point
        // (on a généré l'oid s'il manquait)
        def._afterStore.call(entity, this)
      } else {
        this()
      }
    }).seq(function () {
      // On appelle le onLoad() car l'état de l'entité en BDD a changé,
      // comme si l'entity avait été "rechargée".
      entity.onLoad()
      callback(null, entity)
    }).catch(function (error) {
      // on veut détecter les erreurs de doublon pour rendre le message plus intelligible
      const matches = /^E11000 duplicate key error collection: ([^ ]+) index: ([^ ]+) dup key: { [^:]*: (.*) }$/.exec(error.message)
      if (matches) {
        const [, collectionFullName, mongoIndexName, duplicateValue] = matches
        const entityName = collectionFullName.split('.').pop()

        // cf objet retourné par EntityDefinition.defineIndex
        const indexName = (def.indexesByMongoIndexName[mongoIndexName] && def.indexesByMongoIndexName[mongoIndexName].indexName) || mongoIndexName
        if (indexName === mongoIndexName) {
          console.error(Error(`pas trouvé d’index correspondant à ${collectionFullName}:${mongoIndexName}`))
        }
        const dupError = Error(`Impossible d’enregistrer pour cause de doublon (valeur ${duplicateValue} en doublon pour ${entityName}.${indexName})`)
        Object.assign(dupError, {
          original: error,
          type: 'duplicate',
          entityName,
          indexName,
          mongoIndexName
        })
        // l'objet n'a pas été stocké en base, faut virer l'oid si on l'avait ajouté
        if (isNew) delete entity.oid
        // on appelle pas onLoad, c'est onDuplicate qui fera un autre store (qui ajoutera le onLoad)
        // ou autre chose…
        if (def._onDuplicate) def._onDuplicate.call(entity, dupError, callback)
        else callback(dupError)
      } else {
        callback(error)
      }
    })
  }

  /**
   * Reconstruit les index (en fait un simple store avec $byPassDuplicate)
   * @param {Entity~entityCallback} callback
   */
  reindex (callback) {
    // faut pouvoir réindexer d'éventuel doublons pour mieux les trouver ensuite
    this.$byPassDuplicate = true
    this.store(callback)
  }

  /**
   * Restore un élément supprimé par {@link Entity#softDelete}.
   * ATTENTION, cela n'affecte que la propriété __deletedAt et n'enregistre pas
   * d'éventuelle modifications faites sur l'entity (dans ce cas
   * il faut faire un {@link Entity#markToRestore} suivi d'un {@link Entity#store}
   * @param {Entity~entityCallback} callback
   */
  restore (callback) {
    const entity = this
    const def = this.definition

    flow().seq(function () {
      if (!entity.oid) return this('Impossible de restaurer une entité sans oid')
      def.getCollection().updateOne({
        _id: entity.oid
      }, {
        // la valeur '' ne change rien, cf https://docs.mongodb.com/manual/reference/operator/update/unset/
        $unset: { __deletedAt: '' }
      }, this)
    }).seq(function () {
      // l'update mongo a fonctionné, il faut mettre à jour notre objet
      entity.__deletedAt = null
      // On appelle le onLoad() car l'état de l'entité en BDD a changé,
      // comme si l'entity avait été "rechargée".
      entity.onLoad()
      callback(null, entity)
    }).catch(callback)
  }

  /**
   * Imposera la restauration au prochain store si c'était un objet softDeleted
   * (ne fait rien sinon)
   * @return {Entity}
   */
  markToRestore () {
    this.__deletedAt = null
    return this
  }

  /**
   * Effectue une suppression "douce" de l'entité ({@link Entity#restore} pour la récupérer)
   * @param {Entity~entityCallback} callback
   */
  softDelete (callback) {
    if (!this.oid) return callback(new Error('Impossible de softDelete une entité qui n\'a pas encore été sauvegardée'))
    this.__deletedAt = new Date()
    this.store(callback)
  }

  /**
   * Efface cette instance d'entité en base (et ses index) puis appelle callback
   * avec une éventuelle erreur
   * @param {simpleCallback} callback
   */
  delete (callback) {
    const entity = this
    // @todo activer ce throw ?
    // if (!entity.oid) throw new Error('Impossible d’effacer une entity sans oid')
    if (!entity.oid) return callback()
    const def = this.definition
    flow().seq(function () {
      if (def._beforeDelete) def._beforeDelete.call(entity, this)
      else this()
    }).seq(function () {
      def.getCollection().deleteOne({ _id: entity.oid }, { w: 1 }, callback)
    }).catch(callback)
  }
}

module.exports = Entity