Source: entities/EntityDefinition.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 Ajv = require('ajv')
const AjvKeywords = require('ajv-keywords')
const AjvErrors = require('ajv-errors')
const AjvErrorsLocalize = require('ajv-i18n/localize/fr')
const Entity = require('./Entity')
const EntityQuery = require('./EntityQuery')
const { isAllowedIndexType } = require('./internals')
const flow = require('an-flow')
const log = require('an-log')('EntityDefinition')

// pour marquer les index mis par lassi (et ne pas risquer d'en virer des mis par qqun d'autre,
// internes à mongo par ex, genre _id_…)
const INDEX_PREFIX = 'entity_index_'

// Ces index sont particuliers :
// - ils sont imposés sur chaque entity
// - oid n'en est pas vraiment un car c'est une propriété de data mais mappé sur le _id du document au store
// - __deletedAt est mis par softDelete ou enlevé par restore, stocké dans le document mais pas dans _data
// - ils sont affectés au store => pas besoin de callback
const BUILT_IN_INDEXES = {
  oid: {
    fieldType: 'string',
    indexName: '_id',
    mongoIndexName: '_id_',
    useData: false,
    path: '_id',
    indexOptions: {}
  },
  __deletedAt: {
    fieldType: 'date',
    indexName: '__deletedAt',
    mongoIndexName: '__deletedAt',
    useData: false,
    path: '__deletedAt',
    indexOptions: {}
  }
}

/**
 * Index d'entity
 * @typedef indexDefinition
 * @property {string} [fieldType] Si précisé il y aura du cast avant store et sur les arguments des EntityQuery (ça empêche d'utiliser directement la valeur de _data comme index)
 * @property {string} indexName
 * @property {object} [indexOptions]
 * @property {boolean} [indexOptions.unique]
 * @property {boolean} [indexOptions.sparse]
 * @property {string} mongoIndexName
 * @property {string} path Le chemin de l'index dans le document mongo (indexName ou _data.indexName suivant useData)
 * @property {boolean} useData
 */

/**
 * @callback simpleCallback
 * @param {Error} [error]
 */

/**
 * Définition d'une entité, avec les méthodes pour la définir
 * mais aussi récupérer sa collection, une EntityQuery, etc.
 */
class EntityDefinition {
  /**
   * Construction d'une définition d'entité. Passez par la méthode {@link Component#entity} pour créer une entité.
   * @constructor
   * @param {String} name le nom de l'entité
   */
  constructor (name) {
    this.name = name
    this.indexes = {}
    this.indexesByMongoIndexName = {}
    /**
     * La définition de l'index text si y'en a un
     * @type {{path: string, indexName: string, weigth: number}}
     * @private
     */
    this._textSearchFields = null

    /* Validation */
    this.schema = null
    this._ajv = null
    this._ajvValidate = null
    this._skipValidation = false
    this._toValidateOnChange = {}
    this._trackedAttributes = {}
    this._toValidate = []
  }

  /**
   * Finalisation de l'objet Entité, appelé en fin de définition, avant initialize
   * @param {Entities} entities le conteneur d'entités.
   * @return {EntityDefinition} pour chaînage éventuel
   * @private
   */
  _bless (entities) {
    if (this.configure) this.configure()
    this.entities = entities
    if (!this.entityConstructor) {
      this.entityConstructor = function () {
        // Théoriquement il aurait fallut appeler le constructeur d'Entity avec Entity.call(this, ...), mais
        // 1. c'est impossible car Entity est une classe, qu'il faut instancier avec 'new'
        // 2. Entity n'a pas de constructeur, donc ça ne change pas grand chose
      }
      this.entityConstructor.prototype = Object.create(Entity.prototype)
    }
    return this
  }

  /**
   * Initialise les index (au boot)
   * @param {errorCallback} cb
   * @private
   */
  _initialize (cb) {
    log(this.name, 'initialize')
    this._initializeIndexes(error => {
      if (error) return cb(error)
      this._initializeTextSearchFieldsIndex(cb)
    })
  }

  /**
   * Init des indexes
   * - virent ceux qu'on avait mis et qui ont disparu de l'entity
   * - ajoute ceux qui manquent
   * @param {indexesCallback} cb rappelée avec (error, createdIndexes), le 2e argument est undefined si on a rien créé
   */
  _initializeIndexes (cb) {
    const def = this
    const coll = def.getCollection()
    const existingIndexes = {}
    flow().seq(function () {
      def.getMongoIndexes(this)
      // on parse l'existant
    }).seqEach(function (existingIndex) {
      const mongoIndexName = existingIndex.name
      // si c'est un index text on s'en occupe pas, c'est initializeTextSearchFieldsIndex qui verra plus tard
      if (/^text_index/.test(mongoIndexName)) return this()

      if (def.indexesByMongoIndexName[mongoIndexName] || _.some(BUILT_IN_INDEXES, (index) => index.mongoIndexName === mongoIndexName)) {
        // la notion de type de valeur à indexer n'existe pas dans mongo.
        // seulement des type d'index champ unique / composé / texte / etc.
        // https://docs.mongodb.com/manual/indexes/#index-types
        // ici on boucle sur les index ordinaire, faudrait vérifier que c'est pas un composé ou un unique,
        // mais vu qu'il a un nom à nous… il a été mis par nous avec ce même code donc pas la peine de trop creuser.
        // faudra le faire si on ajoute les index composés et qu'on utilise def.indexes aussi pour eux
        existingIndexes[mongoIndexName] = existingIndex
        log(def.name, `index ${mongoIndexName} ok`)
        return this()
      }
      // si on est toujours là c'est un index qui n'est plus défini,
      // on met un message différent suivant que c'est un index lassi ou pas
      if (RegExp(`^${INDEX_PREFIX}`).test(mongoIndexName)) {
        log(def.name, `index ${mongoIndexName} existe dans mongo mais plus dans l'Entity => DROP`, existingIndex)
      } else {
        log(def.name, `index ${mongoIndexName} existe dans mongo mais n’est pas un index lassi => DROP`, existingIndex)
      }
      // on le vire
      coll.dropIndex(mongoIndexName, this)
    }).seq(function () {
      // et on regarde ce qui manque
      const indexesToAdd = []
      // par commodité, on ajoute __deletedAt aux index ici et l'enlève juste après le forEach
      def.indexes.__deletedAt = BUILT_IN_INDEXES.__deletedAt
      _.forEach(def.indexes, ({ path, mongoIndexName, indexOptions }) => {
        if (existingIndexes[mongoIndexName]) return
        // directement au format attendu par mongo
        // cf https://docs.mongodb.com/manual/reference/command/createIndexes/
        indexesToAdd.push({ key: { [path]: 1 }, name: mongoIndexName, ...indexOptions })
        log(def.name, `index ${mongoIndexName} n’existait pas => création`)
      })
      delete def.indexes.__deletedAt

      // console.log(`création des ${indexesToAdd.length} indexes`, indexesToAdd)
      if (indexesToAdd.length) coll.createIndexes(indexesToAdd, cb)
      else cb()
    }).catch(cb)
  } // _initializeIndexes

  /**
   * Initialise l'index text (qui doit être unique)
   * @see https://docs.mongodb.com/manual/core/index-text/
   * @param callback
   */
  _initializeTextSearchFieldsIndex (callback) {
    /**
     * Crée l'index texte pour cette entité
     * @param {simpleCallback} cb
     * @private
     */
    function createIndex (cb) {
      // Pas de nouvel index à créer
      if (!def._textSearchFields) return cb()

      const options = {
        name: indexName,
        default_language: 'french', // @todo lire la conf pour le rendre paramétrable
        weights: {}
      }
      const keys = {}
      def._textSearchFields.forEach(function ({ path, weight }) {
        keys[path] = 'text'
        // @see https://docs.mongodb.com/manual/reference/method/db.collection.createIndex/#options-for-text-indexes
        options.weights[path] = weight
      })
      dbCollection.createIndex(keys, options, cb)
    }

    /**
     * Passe le nom du 1er index texte (normalement le seul)
     * @param {simpleCallback} cb
     */
    function findFirstExistingTextIndex (cb) {
      def.getMongoIndexes(function (error, indexes) {
        if (error) return cb(error)
        // le 1er index dont le nom commence par text_index
        var textIndex = indexes && _.find(indexes, index => /^text_index/.test(index.name))

        cb(null, textIndex ? textIndex.name : null)
      })
    }

    const def = this
    const dbCollection = def.getCollection()
    const indexName = def._textSearchFields
      ? 'text_index_' + def._textSearchFields.map(({ indexName }) => indexName).join('_')
      : null

    flow()
      .seq(function () {
        findFirstExistingTextIndex(this)
      })
      .seq(function (oldTextIndex) {
        const next = this

        if (indexName === oldTextIndex) {
          // Index déjà créé pour les champs demandés (ou déjà inexistant si null === null), rien d'autre à faire
          if (oldTextIndex) log(def.name, `index ${oldTextIndex} ok`)
          return callback()
        }

        if (!oldTextIndex) {
          // Pas d'index à supprimer, on passe à la suite
          return next()
        }

        // Sinon, on supprime l'ancien index pour pouvoir créer le nouveau
        log(def.name, `index text_index_* a ${indexName ? 'changé' : 'disparu'} dans l'Entity => DROP ${oldTextIndex}`)
        dbCollection.dropIndex(oldTextIndex, this)
      })
      .seq(function () {
        if (!indexName) return callback()
        log(def.name, `index ${indexName} n’existait pas => création`)
        createIndex(this)
      })
      .seq(function () {
        log(def.name, `index ${indexName} créé`)
        callback()
      })
      .catch(callback)
  } // _initializeTextSearchFieldsIndex

  /**
   * @callback validationCallback
   * @param {Error} error
   * @param {Object} data la valeur de la promesse résolue par la fn retournée par ajv.compile
   */
  /**
   * Valide l'entity avec son schéma (ne fait rien si y'a pas de schéma)
   * @private
   * @param {Entity} entity
   * @param {validationCallback} cb
   */
  _validateEntityWithSchema (entity, cb) {
    if (!this._ajvValidate) return cb()

    this._ajvValidate(entity.values())
      .then((data) => cb(null, data))
      .catch((err) => {
        // Traduit les messages d'erreur en français
        AjvErrorsLocalize(err.errors)

        // On enlève les erreurs de certains mots clés qui ne nous interéssent pas particulièrement
        err.errors = err.errors.filter((error) => error.keyword !== 'if')

        // On modifie quelques erreurs pour les rendres plus lisibles
        err.errors = err.errors.map((error) => {
          // pour additionalProperties c'est clair, on a pas besoin du contenu
          if (error.keyword === 'additionalProperties') {
            error.message = `${error.message} : "${error.params.additionalProperty}"`
          } else {
            // mais pour les autres on veut la valeur d'origine qui provoque l'erreur
            const props = error.dataPath.split('/').slice(1)
            const value = props.reduce((acc, prop) => acc[prop], entity)
            try {
              const stringValue = JSON.stringify(value)
              error.message = `${error.message} (oid: ${entity.oid} value: ${stringValue})`
            } catch (error) {}
          }
          return error
        })

        // Génère un message d'erreur qui aggrège les erreurs de validations
        err.message = this._ajv.errorsText(err.errors, { dataVar: this.name })

        cb(err)
      })
  }

  /**
   * Ajoute un traitement après stockage.
   * @param {simpleCallback} fn fonction à exécuter qui doit avoir une callback en paramètre (qui n'aura pas d'arguments)
   */
  afterStore (fn) {
    if (fn.length !== 1) throw Error('afterStore must handle a callback (given function hasn’t length of 1)')
    this._afterStore = fn
  }

  /**
   * Ajoute un traitement avant suppression
   * @param {simpleCallback} fn fonction à exécuter qui doit avoir une callback en paramètre (qui n'aura pas d'arguments)
   */
  beforeDelete (fn) {
    if (fn.length !== 1) throw Error('beforeDelete must handle a callback (given function hasn’t length of 1)')
    this._beforeDelete = fn
  }

  /**
   * Ajoute un traitement avant stockage.
   * @param {simpleCallback} fn fonction à exécuter qui doit avoir une callback en paramètre (qui n'aura pas d'arguments)
   */
  /**
   * Modifie ou valide une entity (mise en contexte, elle sera le this de cette fct)
   * @callback entityContextCallback
   * @param {simpleCallback} cb à appeller avec une erreur éventuelle quand les modif seront finies
   */

  /**
   * Ajoute une fonction à appliquer avant store
   * @param {entityContextCallback} beforeStoreCallback
   */
  beforeStore (fn) {
    if (fn.length !== 1) throw Error('beforeStore function must handle a callback (given function hasn’t length of 1)')
    this._beforeStore = fn
  }

  /**
   * Ajoute un constructeur (appelé par create avec l'objet qu'on lui donne), s'il n'existe pas
   * le create affectera à l'entité toutes les valeurs qu'on lui passe
   * @param {function} fn Constructeur
   */
  construct (fn) {
    this._construct = fn
  }

  /**
   * Passe à cb le nb d'entity (hors softDeleted, passer par EntityQuery#count si on les veut)
   * @param {EntityQuery~CountCallback} cb
   */
  count (cb) {
    this.getCollection().countDocuments({ __deletedAt: { $eq: null } }, cb)
  }

  /**
   * Compte le nb d'entité pour chaque valeur de l'index demandé
   * @param {string} index
   * @param {EntityQuery~CountByCallback} cb
   */
  countBy (index, cb) {
    this.match().countBy(index, cb)
  }

  /**
   * Retourne une instance {@link Entity} à partir de la définition
   * (appelera defaults s'il existe, puis construct s'il existe et Object.assign sinon)
   * Attention, si la fonction passée à construct n'attend pas d'argument,
   * toutes les propriétés de values seront affectée à l'entité !
   * @todo virer ce comportement et ajouter dans les constructeurs qui l'utilisaient un `Object.assign(this, values)`
   * @param {Object=} values Des valeurs à injecter dans l'objet.
   * @return {Entity} Une instance de l'entité
   */
  create (values) {
    var instance = new this.entityConstructor() // eslint-disable-line new-cap
    instance.setDefinition(this)
    if (this._defaults) {
      this._defaults.call(instance)
    }
    if (this._construct) {
      this._construct.call(instance, values)
      // Si la fonction passée en constructeur ne prend aucun argument,
      // on ajoute d'office les values passées au create dans l'entity
      // (si le constructeur ne les a pas créées)
      if (values && this._construct.length === 0) {
        Object.assign(instance, values)
      }
    } else {
      if (values) Object.assign(instance, values)
    }

    if (!instance.isNew()) {
      instance.onLoad()
    }

    return instance
  }

  /**
   * Ajoute un initialisateur, qui sera toujours appelé par create (avant un éventuel construct)
   * @param {function} fn La fonction qui initialisera des valeurs par défaut (sera appelée sans arguments)
   */
  defaults (fn) {
    this._defaults = fn
  }

  /**
   * Ajoute un indexe à l'entité. Contrairement à la logique SGBD, on ne type pas
   * l'indexe. En réalité il faut comprendre un index comme "Utilise la valeur du
   * champ XXX et indexe-la".
   *
   * Une callback peut être fournie pour fabriquer des valeurs virtuelles. Par exemple :
   * ```javascript
   *  entity.index('age', 'integer', function() {
   *    return (new Date()).getFullYear() - this.born.getFullYear();
   *  });
   * ```
   *
   * @param {String} indexName Nom du champ à indexer (ou de l'index virtuel si on passe une callback)
   * @param {String} [fieldType] Type du champ à indexer ('integer', 'string', 'date')
   *                                      ce qui va entrainer du cast à l'indexation et à la query (cf. castToType)
   * @param {Object} [indexOptions] Options d'index mongo ex: {unique: true, sparse: true}
   * @param {boolean} [indexOptions.sparse=false] si true l'index n'est pas sauvegardé
   * @param {boolean} [indexOptions.unique=false] si true le store plantera si la valeur existe déjà en base
   * @param {function} [indexOptions.normalizer] une fonction à appliquer sur la valeur de l'index avant stockage
   *                                             (mais après callback éventuelle)
   * @param {function} [callback] Pour définir la valeur de l'index, appelé avec un .call(entity).
   *                              L'index est alors "virtuel" car il ne dépend pas d'un seul champ,
   *                              mieux vaut ne pas lui donner le même nom qu'un champ
   *                              si on fait trop de transformations
   * @return {EntityDefinition} pour chaînage
   */
  defineIndex (indexName, ...params) {
    if (this.indexes[indexName]) throw Error(`L’index ${indexName} a déjà été défini`)
    if (BUILT_IN_INDEXES[indexName]) throw new Error(`${indexName} est un index imposé par lassi, il ne peut pas être redéfini`)
    let callback
    let indexOptions = {}
    let fieldType
    // On récupère les paramètres optionnels: fieldType, indexOptions, callback
    // en partant de la fin. Heureusement ils ont tous des types différents !
    let param = params.pop()
    if (typeof param === 'function') {
      callback = param
      param = params.pop()
    }

    if (typeof param === 'object') {
      indexOptions = param
      if (indexOptions.normalizer) {
        if (typeof indexOptions.normalizer !== 'function') throw Error('L’option normalizer doit être une fonction')
        // avec ou sans callback, on applique le normalizer (en dernier)
        const initialCb = callback
        // pas de fat arrow, on est appelé via un call
        callback = function () {
          // si y'avait une callback on l'appelle en premier
          const indexValue = initialCb ? initialCb.call(this) : this[indexName]
          if (Array.isArray(indexValue)) return indexValue.map(indexOptions.normalizer)
          return indexOptions.normalizer(indexValue)
        }
      }
      param = params.pop()
    }

    if (typeof param === 'string') {
      fieldType = param
      if (!isAllowedIndexType(fieldType)) throw new Error(`Type d’index ${fieldType} non géré`)
    }

    // Pour l'instant le seul cas où on peut se permettre d'indexer directement l'attribute dans _data
    // est le cas où ces conditions sont remplies :
    // - l'index n'est pas sparse, car dans ce cas si sa valeur est null|undefined buildIndexes()
    //   ne met pas la propriété d'index dans le doc mongo (cf commentaire dans cette fonction)
    //   (le risque serait qu'un bout de code qui fait du `if (entity.prop === null)` ou
    //    `if (hasProp(entity, 'prop'))` ne fonctionne plus lorsque l'index prop prend
    //    l'attribut sparse)
    // - l'index n'a pas de fieldType car buildIndexes() et buildQuery() font du cast sur la valeur indexée
    //
    // On pourrait se débarrasser de fieldType, mais vu ce que l'on fait dans castToType
    // ça peut avoir des répercussions fâcheuses (par ex `.match(42)` remonte les valeurs string '42'
    // si y'a un type string, et ça ne fonctionnerait plus si on enlevait le type sur l'index).
    // Il faudrait donc d'abord supprimer le type dans toutes les définitions d'index des applis
    // qui utilisent lassi avant de le supprimer de lassi
    // Avant de faire cela, cela serait sécurisant de
    // - vérifier le type de l'argument passé à match, qui devra être le même que le jsonShema
    // - vérifier dans defineIndex que le champ a un type dans le shema
    const useData = !callback && !indexOptions.sparse && !fieldType

    const mongoIndexName = this.getMongoIndexName(indexName, useData, indexOptions)
    // en toute rigueur il faudrait vérifier que c'est de l'ascii pur,
    // en cas d'accents dans name 127 chars font plus de 128 bytes
    if (mongoIndexName.length > 128) throw new Error(`Nom d’index trop long, 128 max pour mongo dont ${INDEX_PREFIX.length} occupés par notre préfixe`)

    const index = {
      fieldType,
      indexName,
      useData,
      path: useData ? `_data.${indexName}` : indexName,
      mongoIndexName,
      indexOptions,
      callback
    }

    this.indexes[indexName] = index
    this.indexesByMongoIndexName[mongoIndexName] = index
    return this
  }

  /**
   * Ajoute une méthode au prototype du constructeur d'entity
   * @param {string} name Nom de la méthode
   * @param {function} fn Méthode
   */
  defineMethod (name, fn) {
    this.entityConstructor.prototype[name] = fn
  }

  /**
   * Défini les index de recherche fullText
   * @param {string[]|Array[]} fields la liste des champs à prendre en compte pour la recherche fulltext, passer un tableau [name, weight] pour fixer un poid ≠ 1 sur le champ concerné
   */
  defineTextSearchFields (fields) {
    const def = this
    def._textSearchFields = fields.map((field) => {
      let indexName, weight
      if (typeof field === 'string') {
        indexName = field
        weight = 1
      } else if (Array.isArray(field)) {
      // si on nous passe un array, le 1er doit être un nom et le 2e un poid
        if (typeof field[0] !== 'string' || typeof field[1] !== 'number') {
          throw new TypeError('Si vous précisez un champ à indexer en texte sous forme d’un Array, cela doit être [fieldName: string, weight: number]')
        }
        indexName = field[0]
        weight = field[1]
        weight = Math.min(99, Math.max(1, Math.round(weight)))
        if (weight !== field[1]) log.error(Error(`Pour un champ texte weight doit être un entier entre 1 et 99 (${field[1]} fourni, ramené à ${weight}`))
      } else {
        throw new TypeError('defineTextSearchFields veut une liste d’index, chacun étant une string ou un Array [fieldName: string, weight: number]')
      }

      let path = `_data.${indexName}`
      // si le champ est indexé par ailleurs, on prend son path (pour indexer
      // les valeurs retournées par sa callback plutôt que celle du champ)
      if (def.hasIndex(indexName)) path = def.getIndex(indexName).path

      return {
        indexName,
        path,
        weight
      }
    })
  }

  /**
   * drop la collection
   * @param {simpleCallback} cb
   */
  flush (cb) {
    if (!this.entities.db) return cb(Error('entities n’a pas été initialisé, flush impossible'))
    // Si la collection n'existe pas, getCollection renvoie quand même un objet
    // mais "MongoError: ns not found" est renvoyé sur le drop
    this.getCollection().drop(function (error) {
      if (error) {
        if (/ns not found/.test(error.message)) return cb()
        if (/ns does not exist/.test(error.message)) return cb()
        return cb(error)
      }
      cb()
    })
  }

  /**
   * Retourne l'objet Collection de mongo de cette EntityDefinition
   * @return {Collection}
   */
  getCollection () {
    if (!this.entities.db) throw Error('entities n’a pas été initialisé (ou la connexion à mongo a été fermée), impossible de retourner la collection')
    let coll = this.entities.db.collection(this.name)
    if (!coll) coll = this.entities.db.createCollection(this.name)
    return coll
  }

  /**
   * Retourne l'objet db de la connexion à Mongo
   * À n'utiliser que dans des cas très particuliers pour utiliser directement des commandes du driver mongo
   * Si lassi ne propose pas la méthode pour votre besoin, il vaudrait mieux l'ajouter à lassi
   * plutôt que d'utiliser directement cet objet, on vous le donne à vos risques et périls…
   * @return {Db}
   */
  getDb () {
    return this.entities.db
  }

  /**
   * Retourne la définition de l'index demandé
   * @param {string} indexName
   * @return {indexDefinition}
   * @throws {Error} si index n'est pas un index défini
   */
  getIndex (indexName) {
    if (BUILT_IN_INDEXES[indexName]) return BUILT_IN_INDEXES[indexName]

    if (!this.hasIndex(indexName)) throw new Error(`L’entity ${this.name} n’a pas d’index ${indexName}`)
    return this.indexes[indexName]
  }

  /**
   * @callback indexesCallback
   * @param {Error} [error]
   * @param {Object[]} [createdIndexes] tableau d'index mongo (retourné par listIndexes ou createIndexes)
   */
  /**
   * Récupère tous les index existants
   * @param {indexesCallback} cb
   */
  getMongoIndexes (cb) {
    this.getCollection().listIndexes().toArray(function (error, indexes) {
      if (error) {
        // de mongo 3.2 à 4.0 les messages évoluent
        if (
          /^ns does not exist/.test(error.message) || // 4.0
          error.message === 'no collection' ||
          error.message === 'no database' ||
          /^Collection.*doesn't exist$/.test(error.message) ||
          /^Database.*doesn't exist$/.test(error.message)
        ) {
          // Ce cas peut se produire si la collection/database vient d'être créée
          // il n'y a donc pas d'index existant
          return cb(null, [])
        }
        return cb(error)
      }

      return cb(null, indexes)
    })
  }

  /**
   * Retourne le nom de l'index mongo associé à un champ
   * @param indexName
   * @return {string}
   */
  getMongoIndexName (indexName, useData, indexOptions = {}) {
    if (BUILT_IN_INDEXES[indexName]) return BUILT_IN_INDEXES[indexName].mongoIndexName
    let name = `${INDEX_PREFIX}${indexName}`

    // quand un index passe de calculé à non calculé, on veut le regénérer donc on change son nom
    if (useData) name += '-data'

    // On donne un nom différent à un index unique et/ou sparse ce qui force lassi à recréer l'index
    // si on ajoute ou enlève l'option
    ;['unique', 'sparse'].forEach((opt) => {
      if (indexOptions[opt]) name += `-${opt}`
    })

    return name
  }

  /**
   * Pour savoir si un index est défini
   * @param indexName
   * @return {boolean}
   */
  hasIndex (indexName) {
    return !!this.indexes[indexName]
  }

  /**
   * Retourne un requeteur (sur lequel on pourra chaîner les méthodes de {@link EntityQuery})
   * @param {string} [index] Un index à matcher en premier, on peut en mettre plusieurs
   * @return {EntityQuery}
   */
  match () {
    const query = new EntityQuery(this)
    if (arguments.length) query.match.apply(query, Array.prototype.slice.call(arguments))
    return query
  }

  /**
   * Erreur provoqué par un doublon au store
   * @typedef duplicateError
   * @type Error
   * @property {string} type Toujours 'duplicate'
   * @property {Error} original L'erreur originale retournée par mongo
   * @property {string} entityName Le nom de l'entity
   * @property {string} indexName Le nom de l'index (1er argument passé à {@link EntityDefinition#defineIndex})
   * @property {string} mongoIndexName Le nom de l'index mongo (créé par lassi)
   */
  /**
   * Appelée en cas d'erreur de doublon, son contexte (son this) sera l'entité ayant provoqué le plantage
   * @callback duplicateCallback
   * @param {duplicateError} error L'erreur de doublon
   * @param {storeCallback} callback La callback passée au store
   */
  /**
   * Ajoute une fct pour traiter un plantage pour cause de doublon au store
   * @param {duplicateCallback}
   */
  onDuplicate (fn) {
    if (typeof fn !== 'function') throw Error('onDuplicate prend une fonction en argument')
    if (fn.length !== 2) throw Error('La fonction passée à onDuplicate doit avoir 2 arguments error et callback')
    this._onDuplicate = fn
  }

  /**
   * Ajoute un traitement après récupération de l'entité en base de donnée
   *
   * ATTENTION: cette fonction sera appelée très souvent (pour chaque entity retournée) et doit se limiter
   *            à des traitements très simples.
   *            Contrairent aux autres before* ou after*, elle ne prend pas de callback pour le moment car dangereux
   *            en terme de performance - on ne veut pas d'appel asynchrone sur ce genre de fonction - et plus compliqué
   *            à implémenter ici.
   *            Par exemple, sur une entité utilisateur:
   *
   *            this.onLoad(function {
   *                this.$dbPassword = this.password // permettra de voir plus tard si le password a été changé
   *            })
   *
   * @param {simpleCallback} fn fonction à exécuter qui ne prend pas de paramètre
   */
  onLoad (fn) {
    this._onLoad = fn
  }

  /**
   * Permet de désactiver / réactiver la validation au beforeStore
   * @param {boolean} skipValidation si true, on ne vérifiera pas la validation avant le store
   */
  setSkipValidation (skipValidation) {
    this._skipValidation = skipValidation
  }

  /**
   * Marque l'attribut comme étant à suivre, pour voir le changement entre le chargement depuis la base et le store
   * @param {string} attributeName
   */
  trackAttribute (attributeName) {
    this._trackedAttributes[attributeName] = true
  }

  /**
   * Marque les attributs comme étant à suivre, pour voir le changement entre le chargement depuis la base et le store
   * @param {string[]} attributeName
   */
  trackAttributes (attributeNames) {
    attributeNames.forEach((att) => this.trackAttribute(att))
  }

  /**
   * Ajoute une fonction de validation
   * @param {function} validateFn
   */
  validate (validateFn) {
    this._toValidate.push(validateFn)
  }

  /**
   * Définit un json schema pour l'entity, validé lors d'un appel à isValid() ou avant le store d'une entity
   * Le deuxième argument permet d'ajouter des keywords personnalisés
   *
   * @param {Object} schema json schema à valider
   * @param {Object} addKeywords "keywords" supplémentaires à définir sur ajv, @link {https://github.com/epoberezkin/ajv#api-addkeyword}
   */
  validateJsonSchema (schema, addKeywords = {}) {
    if (this.schema) throw new Error(`validateJsonSchema a déjà été appelé pour l'entity ${this.name}`)

    this._ajv = new Ajv({ allErrors: true, jsonPointers: true })
    // Ajv options allErrors and jsonPointers are required for AjxErrors
    AjvErrors(this._ajv)
    AjvKeywords(this._ajv, 'instanceof')

    _.forEach(addKeywords, (definition, keyword) => {
      this._ajv.addKeyword(keyword, definition)
    })

    this.schema = Object.assign(
      {
        $async: true, // pour avoir une validation uniforme, on considère tous les schémas asynchrones
        additionalProperties: false, // par défaut, on n'autorise pas les champs non-déclarés dans les properties
        type: 'object', // toutes les entities sont des objets
        title: this.name
      },
      schema
    )

    this._ajvValidate = this._ajv.compile(this.schema)
  }

  /**
   * Ajoute une fonction de validation sur un attribut particulier et ajoute un trackAttribute dessus.
   * (donc inutile de faire ce trackAttribute par ailleurs)
   * @param {string|string[]} attributeName (on peut en passer plusieurs, ils auront tous la même fct de validation)
   * @param {function} validateFn
   */
  validateOnChange (attributeName, validateFn) {
    if (_.isArray(attributeName)) {
      attributeName.forEach((att) => this.validateOnChange(att, validateFn))
      return
    }
    if (!this._toValidateOnChange[attributeName]) {
      this._toValidateOnChange[attributeName] = []
    }
    this._toValidateOnChange[attributeName].push(validateFn)
    this.trackAttribute(attributeName)
  }
}

module.exports = EntityDefinition

/**
 * Callback à rappeler sans argument
 * @callback simpleCallback
 */