mbroadst/thinkagain

View on GitHub
lib/model.js

Summary

Maintainability
F
1 wk
Test Coverage
'use strict';
const Promise = require('bluebird'),
      EventEmitter = require('events'),
      Document = require('./document'),
      Errors = require('./errors'),
      Query = require('./query'),
      util = require('./util');

class Model extends EventEmitter {

  /*
  * Constructor for a Model. Note that this is not what `thinkagain.createModel`
  * returns. It is the prototype of what `thinkagain.createModel` returns.
  * The whole chain being:
  * document.__proto__ = new Document(...)
  * document.__proto__.constructor = model (returned by thinkagain.createModel
  * document.__proto__._model = instance of Model
  * document.__proto__.constructor.__proto__ = document.__proto__._model
  */
  constructor(name, validate, options, thinkagain) {
    super();

    /**
     * Name of the table used
     * @type {string}
     */
    this._name = name;

    // We want a deep copy
    options = options || {};
    this._options = {};
    this._options.timeFormat =
      (options.timeFormat != null) ? options.timeFormat : thinkagain._options.timeFormat; // eslint-disable-line
    this._options.validate =
      (options.validate != null) ? options.validate : thinkagain._options.validate; // eslint-disable-line

    this._validate = validate;

    this._pk = (options.pk != null) ? options.pk : 'id'; // eslint-disable-line

    this._table = (options.table != null) ? options.table : {}; // eslint-disable-line
    this._table.primaryKey = this._pk;

    this._thinkagain = thinkagain;

    this._validator = options.validator;

    this._indexes = {}; // indexName -> true
    this._pendingPromises = [];

    this._error = null; // If an error occured, we won't let people save things

    this._listeners = {};
    this._maxListeners = 10;
    this._joins = {};
    this._localKeys = {}; // key used as a foreign key by another model

    // This is to track joins that were not directly called by this model but that we still need
    // to purge the database
    this._reverseJoins = {};

    this._methods = {};
    this._staticMethods = {};
    this._async = {
      init: false,
      retrieve: false,
      save: false,
      validate: false
    };

    this._pre = {
      save: [],
      delete: [],
      validate: []
    };

    this._post = {
      init: [],
      retrieve: [],
      save: [],
      delete: [],
      validate: []
    };
  }

  static new(name, schema, options, thinkagain) {
    let validate = thinkagain.ajv.compile(schema);
    let proto = new Model(name, validate, options, thinkagain);
    proto._initModel = options.init  !== undefined ? !!options.init : true;

    let model = function model(doc, _options) {
      if (!util.isPlainObject(doc)) {
        throw new Errors.ThinkAgainError('Cannot build a new instance of `' + proto._name + '` without an object');
      }

      // We create a deepcopy only if doc was already used to create a document
      if (doc instanceof Document) {
        doc = util.deepCopy(doc);
      }

      util.changeProto(doc, new Document(model, _options));

      // Create joins document. We do it here because `_options` are easily available
      util.loopKeys(proto._joins, (joins, key) => {
        if (doc[key] != null) { // eslint-disable-line
          if ((joins[key].type === 'hasOne') && (doc[key] instanceof Document === false)) {
            doc[key] = new joins[key].model(doc[key], _options); // eslint-disable-line
          } else if ((joins[key].type === 'belongsTo') && (doc[key] instanceof Document === false)) {
            doc[key] = new joins[key].model(doc[key], _options); // eslint-disable-line
          } else if (joins[key].type === 'hasMany') {
            doc.__proto__._hasMany[key] = []; // eslint-disable-line

            for (let i = 0, ii = doc[key].length; i < ii; ++i) {
              if (doc[key][i] instanceof Document === false) {
                doc[key][i] = new joins[key].model(doc[key][i], _options); // eslint-disable-line
              }
            }
          } else if (joins[key].type === 'hasAndBelongsToMany') {
            for (let i = 0, ii = doc[key].length; i < ii; ++i) {
              if (doc[key][i] instanceof Document === false) {
                doc[key][i] = new joins[key].model(doc[key][i], _options); // eslint-disable-line
              }
            }
          }
        }
      });

      let promises = [];
      let promise;
      if (proto._options.validate === 'oncreate') {
        doc._syncValidate(_options);
      }

      if (proto._post.init.length > 0) {
        promise = util.hook({
          postHooks: doc._getModel()._post.init,
          doc: doc,
          async: doc._getModel()._async.init,
          fn: function() {
            return doc;
          }
        });
        if (promise instanceof Promise) promises.push(promise);
      }

      if (promises.length > 0) {
        return Promise.all(promises).then(docs => docs[0]);
      }

      return doc;
    };

    model.__proto__ = proto; // eslint-disable-line

    if (options.init !== false) {
      // Setup the model's table.
      model.tableReady().then();
    } else {
      // We do not initialize the table and suppose that it already exists and
      // is ready.
      model.emit('created');
      model.emit('ready');
    }

    // So people can directly call the EventEmitter from the constructor
    // TOIMPROVE: We should emit everything from the constructor instead of emitting things from
    // the constructor and the instance of Model
    util.loopKeys(EventEmitter.prototype, (emitter, key) => {
      model[key] = function() {
        model._getModel()[key].apply(model._getModel(), arguments);
      };
    });

    return model;
  }

  /**
   * Create the model's table.
   * @return {Promise=} Returns a promise which will resolve when the table is ready.
   */
  tableReady() {
    let model = this._getModel();
    if (!this._initModel) return Promise.resolve();
    if (this._tableReadyPromise) return this._tableReadyPromise;

    // Create the table, or push the table name in the queue.
    let r = model._thinkagain.r;
    this._tableReadyPromise = model._thinkagain.dbReady()
      .then(() => r.tableCreate(model._name, model._table).run())
      .error(error => {
        if (error.message.match(/Table `.*` already exists/)) {
          return;
        }

        model._error = error;
        // Should we throw here?
      });

    return this._tableReadyPromise
      .then(() => {
        this.emit('created');
        if (!this._pendingPromises.length) {
          this.emit('ready');
        }
      });
  }

  /**
   * Get a promise which resolves when the Model's table and
   * all indices have been created.
   */
  ready() {
    let requirements = [];

    // Ensure the Model's table is ready
    requirements.push(this.tableReady());

    // Ensure all other pending promises have been resolved
    requirements.push(this._promisesReady());

    return Promise.all(requirements);
  }

  _promisesReady() {
    let self = this;
    if (this._promisesReadyPromise) return this._promisesReadyPromise;
    let verifyAll = function() {
      return Promise.all(self._pendingPromises)
        .then(() => {
          let allFullfilled = true;
          for (let i = 0, ii = self._pendingPromises.length; i < ii; ++i) {
            if (!self._pendingPromises[i].isFulfilled()) {
              allFullfilled = false;
              break;
            }
          }

          return allFullfilled ? Promise.resolve() : verifyAll();
        });
    };

    this._promisesReadyPromise = verifyAll();
    return this._promisesReadyPromise;
  }

  _waitFor(promise) {
    this._pendingPromises.push(promise);

    // Emit 'ready' when all pending promises have resolved
    if (!this._pendingReady) {
      this._pendingReady = this._promisesReady()
        .then(() => {
          delete this._pendingReady;
          this.emit('ready', this);
        });
    }
  }

  _setError(error) {
    this._getModel()._error = error;
    this.emit('error', error);
  }

  /*
  * Return the options of the model -- call from an instance of Model
  */
  getOptions() {
    return this._options;
  }

  /*
  * Return the instance of Model **when called on the function**
  */
  _getModel() {
    return this.__proto__; // eslint-disable-line
  }

  /*
  * Return the instance of Model
  */
  getTableName() {
    return this._getModel()._name;
  }

  ensureIndex(name, fn, opts) {
    if ((opts === undefined) && (util.isPlainObject(fn))) {
      opts = fn;
      fn = undefined;
    }

    return this._createIndex(name, fn, opts)
      .catch(error => {
        this._getModel()._setError(error);
        throw error;
      });
  }

  _createIndex(name, fn, opts) {
    let model = this._getModel();
    let tableName = this.getTableName();
    let r = model._thinkagain.r;

    if (opts === undefined && util.isPlainObject(fn)) {
      opts = fn;
      fn = undefined;
    }

    let promise = this.tableReady()
      .then(() => r.branch(
        r.table(tableName).indexList().contains(name),
        r.table(tableName).indexWait(name),
        r.branch(
          r.table(tableName).info()('primary_key').eq(name),
          r.table(tableName).indexWait(name),
          r.table(tableName).indexCreate(name, fn, opts)
          .do(() => r.table(tableName).indexWait(name))
        )
      ).run())
      .error(error => {
        // TODO: This regex seems a bit too generous since messages such
        // as "Index `id` was not found on table..." will be accepted.
        // Figure out if this is OK or not.
        if (error.message.match(/^Index/)) return;

        throw error;
      })
      .then(() => { model._indexes[name] = true; });

    this._waitFor(promise);
    return promise;
  }

  /*
  * joinedModel: the joined model
  * fieldDoc: the field where the joined document will be kept
  * leftKey: the key in the model used for the join
  * rightKey: the key in the joined model used for the join
  *
  * The foreign key is stores in the joinedModel
  *
  * Post.hasOne(Author, "author", "id", "postId"
  *                ^- post.id
  *
  * options can be:
  * - init: Boolean (create an index or not)
  * - timeFormat: 'raw'/'native'
  * - validate: 'oncreate'/'onsave'
  */
  hasOne(joinedModel, fieldDoc, leftKey, rightKey, options) {
    if ((joinedModel instanceof Model) === false) {
      throw new Errors.ThinkAgainError('First argument of `hasOne` must be a Model');
    }

    if (fieldDoc in this._getModel()._joins) {
      throw new Errors.ThinkAgainError('The field `' + fieldDoc + '` is already used by another relation.');
    }

    if (fieldDoc === '_apply') {
      throw new Errors.ThinkAgainError('The field `_apply` is reserved by thinkagain. Please use another one.');
    }

    let documentModel = this._getModel();

    // recompile document schema
    let schema = documentModel._validate.schema;
    let joinedModelSchema = joinedModel._getModel()._validate.schema;
    schema.properties[fieldDoc] = {
      anyOf: [ { $ref: joinedModelSchema.id }, { type: 'null' } ]
    };

    this._thinkagain.ajv.removeSchema(schema.id);
    documentModel._validate = this._thinkagain.ajv.compile(schema);

    documentModel._joins[fieldDoc] = {
      model: joinedModel,
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'hasOne'
    };
    joinedModel._getModel()._localKeys[rightKey] = true;

    options = options || {};
    if (options.init === false) return;

    let newIndex = joinedModel._createIndex(rightKey)
      .catch(error => {
        joinedModel._getModel()._setError(error);
        documentModel._setError(error);
      });

    this._waitFor(newIndex);
  }

  /*
  * joinedModel: the joined model
  * fieldDoc: the field where the joined document will be kept
  * leftKey: the key in the model used for the join
  * rightKey: the key in the joined model used for the join
  *
  * The foreign key is store in the model calling belongsTo
  *
  * Post.belongsTo(Author, "author", "authorId", "id"
  *                        ^- author.id
  */
  belongsTo(joinedModel, fieldDoc, leftKey, rightKey, options) {
    if ((joinedModel instanceof Model) === false) {
      throw new Errors.ThinkAgainError('First argument of `belongsTo` must be a Model');
    }

    if (fieldDoc in this._getModel()._joins) {
      throw new Errors.ThinkAgainError('The field `' + fieldDoc + '` is already used by another relation.');
    }

    if (fieldDoc === '_apply') {
      throw new Errors.ThinkAgainError('The field `_apply` is reserved by thinkagain. Please use another one.');
    }

    let documentModel = this._getModel(),
        joinedModelModel = joinedModel._getModel();

    // recompile document schema
    let schema = documentModel._validate.schema;
    let joinedModelSchema = joinedModelModel._validate.schema;
    joinedModelSchema.properties[fieldDoc] = {
      anyOf: [ { $ref: schema.id }, { type: 'null' } ]
    };

    this._thinkagain.ajv.removeSchema(joinedModelSchema.id);
    joinedModelModel._validate = this._thinkagain.ajv.compile(joinedModelSchema);

    documentModel._joins[fieldDoc] = {
      model: joinedModel,
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'belongsTo'
    };
    documentModel._localKeys[leftKey] = true;

    joinedModelModel._reverseJoins[fieldDoc] = {
      model: this,
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'belongsTo'
    };

    options = options || {};
    if (options.init === false) return;

    let newIndex = joinedModel._createIndex(rightKey)
      .catch(error => {
        joinedModelModel._setError(error);
        documentModel._setError(error);
      });

    this._waitFor(newIndex);
  }

  /*
  * joinedModel: the joined model
  * fieldDoc: the field where the joined document will be kept
  * leftKey: the key in the model used for the join
  * rightKey: the key in the joined model used for the join
  *
  * A post has one author, and an author can write multiple posts
  * Author.hasMany(Post, "posts", "id", "authorId"
  *                 ^- author.id
  */
  hasMany(joinedModel, fieldDoc, leftKey, rightKey, options) {
    if ((joinedModel instanceof Model) === false) {
      throw new Errors.ThinkAgainError('First argument of `hasMany` must be a Model');
    }

    if (fieldDoc in this._getModel()._joins) {
      throw new Errors.ThinkAgainError('The field `' + fieldDoc + '` is already used by another relation.');
    }

    if (fieldDoc === '_apply') {
      throw new Errors.ThinkAgainError('The field `_apply` is reserved by thinkagain. Please use another one.');
    }

    let documentModel = this._getModel();

    // recompile document schema
    let schema = documentModel._validate.schema;
    let joinedModelSchema = joinedModel._getModel()._validate.schema;
    schema.properties[fieldDoc] = {
      anyOf: [ { type: 'array', items: { $ref: joinedModelSchema.id } }, { type: 'null' } ]
    };

    this._thinkagain.ajv.removeSchema(schema.id);
    documentModel._validate = this._thinkagain.ajv.compile(schema);

    documentModel._joins[fieldDoc] = {
      model: joinedModel,
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'hasMany'
    };
    joinedModel._getModel()._localKeys[rightKey] = true;

    options = options || {};
    if (options.init === false) return;

    let newIndex = joinedModel._createIndex(rightKey)
      .catch(error => {
        documentModel._setError(error);
        joinedModel._getModel()._setError(error);
      });

    this._waitFor(newIndex);
  }

  /*
  * joinedModel: the joined model
  * fieldDoc: the field where the joined document will be kept
  * leftKey: the key in the model used for the join
  * rightKey: the key in the joined model used for the join
  *
  * Patient.hasAndBelongsToMany(Doctor, "doctors", "id", "id"
  *                     patient.id-^  ^-doctor.id
  *
  * It automatically creates a table <modelName>_<joinedModel> or <joinedModel>_<modelName> (alphabetic order)
  */
  hasAndBelongsToMany(joinedModel, fieldDoc, leftKey, rightKey, options) {
    let link;
    let thinkagain = this._getModel()._thinkagain;
    options = options || {};

    if ((joinedModel instanceof Model) === false) {
      throw new Errors.ThinkAgainError('First argument of `hasAndBelongsToMany` must be a Model');
    }

    if (fieldDoc in this._getModel()._joins) {
      throw new Errors.ThinkAgainError('The field `' + fieldDoc + '` is already used by another relation.');
    }

    if (fieldDoc === '_apply') {
      throw new Errors.ThinkAgainError('The field `_apply` is reserved by thinkagain. Please use another one.');
    }

    if (this._getModel()._name < joinedModel._getModel()._name) {
      link = this._getModel()._name + '_' + joinedModel._getModel()._name;
    } else {
      link = joinedModel._getModel()._name + '_' + this._getModel()._name;
    }

    if (typeof options.type === 'string') {
      link = link + '_' + options.type;
    } else if (typeof options.type !== 'undefined') {
      throw new Errors.ThinkAgainError('options.type should be a string or undefined.');
    }

    let linkModel;
    if (thinkagain.models[link] === undefined) {
      linkModel = thinkagain.createModel(link, {}); // Create a model, claim the namespace and create the table
    } else {
      linkModel = thinkagain.models[link];
    }

    // let documentModel = this._getModel(),
    //     joinedModelModel = joinedModel._getModel();

    // recompile document schema
    // let schema = documentModel._validate.schema;
    // let joinedModelSchema = joinedModelModel._validate.schema;
    // joinedModelSchema.properties[fieldDoc] = { type: 'array', items: { $ref: schema.id } };
    // schema.properties[fieldDoc] = { type: 'array', items: { $ref: joinedModelSchema.id } };
    // this._thinkagain.ajv.removeSchema(schema.id);
    // this._thinkagain.ajv.removeSchema(joinedModelSchema.id);
    // documentModel._validate = this._thinkagain.ajv.compile(schema);
    // joinedModelModel._validate = this._thinkagain.ajv.compile(joinedModelSchema);

    this._getModel()._joins[fieldDoc] = {
      model: joinedModel,
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'hasAndBelongsToMany',
      link: link,
      linkModel: linkModel
    };

    joinedModel._getModel()._reverseJoins[this.getTableName()] = {
      leftKey: leftKey,
      rightKey: rightKey,
      type: 'hasAndBelongsToMany',
      link: link,
      linkModel: linkModel
    };

    if (options.init !== false) {
      let r = this._getModel()._thinkagain.r;

      let query;
      if ((this.getTableName() === joinedModel.getTableName())
        && (leftKey === rightKey)) {
        // The relation is built for the same model, using the same key
        // Create a multi index
        query = r.branch(
          r.table(link).indexList().contains(leftKey + '_' + rightKey),
          r.table(link).indexWait(leftKey + '_' + rightKey),
          r.table(link).indexCreate(leftKey + '_' + rightKey, doc => doc(leftKey + '_' + rightKey), { multi: true })
          .do(() => r.table(link).indexWait(leftKey + '_' + rightKey))
        );
      } else {
        query = r.branch(
          r.table(link).indexList().contains(this.getTableName() + '_' + leftKey),
          r.table(link).indexWait(this.getTableName() + '_' + leftKey),
          r.table(link).indexCreate(this.getTableName() + '_' + leftKey).do(() => r.table(link).indexWait(this.getTableName() + '_' + leftKey))
        ).do(() => r.branch(
            r.table(link).indexList().contains(joinedModel.getTableName() + '_' + rightKey),
            r.table(link).indexWait(joinedModel.getTableName() + '_' + rightKey),
            r.table(link).indexCreate(joinedModel.getTableName() + '_' + rightKey)
            .do(() => r.table(link).indexWait(joinedModel.getTableName() + '_' + rightKey))
          )
        );
      }

      let linkPromise = linkModel.ready()
        .then(() => {
          return query.run()
            .then(() => {
              this._getModel()._indexes[leftKey] = true;
              joinedModel._getModel()._indexes[rightKey] = true;
            })
            .error(error => {
              if (error.message.match(/^Index `/) ||
                  error.message.match(/^Table `.*` already exists/)) {
                return;
              }

              this._getModel()._setError(error);
              joinedModel._getModel()._setError(error);
              throw error;
            });
        })
        .then(() => {
          this._createIndex(leftKey)
            .catch(error => {
              this._getModel()._setError(error);
              joinedModel._getModel()._setError(error);
            });

          joinedModel._createIndex(rightKey)
            .catch(error => {
              this._getModel()._setError(error);
              joinedModel._getModel()._setError(error);
            });
        });

      joinedModel._waitFor(linkPromise);
      this._waitFor(linkPromise);

      return Promise.all([ this.ready(), joinedModel.ready() ]);
    }
  }

  getJoin() {
    let query = new Query(this);
    return query.getJoin.apply(query, arguments);
  }

  removeRelations(relationsToRemove) {
    let query = new Query(this);
    return query.removeRelations(relationsToRemove);
  }

  run(options) {
    let query = new Query(this);
    return query.run(options);
  }

  execute(options) {
    let query = new Query(this);
    return query.execute(options);
  }

  save(docs, options) {
    let r = this._getModel()._thinkagain.r;
    let isArray = Array.isArray(docs);

    if (!isArray) {
      docs = [docs];
    }

    let toSave = docs.length;
    let resolves = [];
    let rejects = [];
    let executeInsert = (resolve, reject) => {
      toSave--;
      resolves.push(resolve);
      rejects.push(reject);

      if (toSave === 0) {
        let copies = [];
        for (let i = 0, ii = docs.length; i < ii; ++i) {
          copies.push(docs[i]._makeSavableCopy());
        }

        let _options;
        if (util.isPlainObject(options)) {
          _options = util.deepCopy(options);
        } else {
          _options = {};
        }

        _options.returnChanges = 'always';
        r.table(this.getTableName()).insert(copies, _options).run()
          .then(results => {
            if (results.errors === 0) {
              // results.changes currently does not enforce the same order as docs
              if (Array.isArray(results.changes)) {
                for (let i = 0, ii = results.changes.length; i < ii; ++i) {
                  docs[i]._merge(results.changes[i].new_val);
                  docs[i]._setOldValue(util.deepCopy(results.changes[i].old_val));
                  docs[i].setSaved();
                  docs[i].emit('saved', docs[i]);
                }
              }
              for (let i = 0, ii = resolves.length; i < ii; ++i) {
                resolves[i]();
              }
            } else {
              //TODO Expand error with more information
              for (let i = 0, ii = rejects.length; i < ii; ++i) {
                rejects[i](new Errors.ThinkAgainError('An error occurred during the batch insert. Original results:\n' + JSON.stringify(results, null, 2)));
              }
            }
          })
          .error(reject);
      }
    };

    let promises = [];
    for (let i = 0, ii = docs.length; i < ii; ++i) {
      if (docs[i] instanceof Document === false) {
        docs[i] = new this(docs[i]); // eslint-disable-line
      }

      let promise = docs[i].validate();
      if (promise instanceof Promise) {
        promises.push(promise);
      }
    }

    let result = Promise.all(promises)
      .then(() => {
        let _promises = [];
        for (let i = 0, ii = docs.length; i < ii; ++i) {
          _promises.push(docs[i]._batchSave(executeInsert));
        }

        return Promise.all(_promises).return(docs);
      });

    return (!isArray) ? result.get(0) : result;
  }

  define(key, fn) {
    this._methods[key] = fn;
  }

  defineStatic(key, fn) {
    this._staticMethods[key] = fn;
    this[key] = function() {
      return fn.apply(this, arguments);
    };
  }


  __createDocument(data, shouldCallHookAndValidate) {
    return Promise.try(() => {
      let doc = new this(data); // eslint-disable-line
      doc.setSaved(true);
      doc._emitRetrieve();

      if (!shouldCallHookAndValidate) {
        return doc;
      }

      // Order matters here, we want the hooks to be executed *before* calling validate
      let promise = util.hook({
        postHooks: doc._getModel()._post.retrieve,
        doc: doc,
        async: doc._getModel()._async.retrieve,
        fn: function() {}
      });

      return (promise instanceof Promise) ?
        promise.then(() => doc.validate()).return(doc) :
        doc.validate().return(doc);
    });
  }

  _parseUngroup(data) {
    for (let i = 0, ii = data.length; i < ii; ++i) {
      for (let j = 0, jj = data[i].reduction.length; j < jj; ++j) {
        data[i].reduction[j] = this.__createDocument(data[i].reduction[j], false);
      }
    }

    return data;
  }

  _parseArray(data) {
    return Promise.map(data, d => this.__createDocument(d, true)).return(data);
  }

  _parseGrouped(data) {
    // If we get a GROUPED_DATA, we convert documents in each group
    if (util.isPlainObject(data) && (data.$reql_type$ === 'GROUPED_DATA')) {
      return Promise.reduce(data.data, (result, d) => {
        if (Array.isArray(d[1])) {
          return Promise
            .map(d[1], dd => this.__createDocument(dd, true))
            .then(docs => result.push({ group: d[0], reduction: docs }))
            .return(result);
        }

        return this.__createDocument(d[1], true)
          .then(doc => result.push({ group: d[0], reduction: doc }))
          .return(result);
      }, []);
    }

    if (data === null) { // makeDocument is true, but we got `null`
      throw new Errors.ThinkAgainError('Cannot build a new instance of `' + this.getTableName() + '` with `null`.');
    }

    return this.__createDocument(data, true);
  }

  _parse(data, ungroup) {
    if (ungroup) {
      return this._parseUngroup(data);
    } else if (Array.isArray(data)) {
      return this._parseArray(data)
        .error(err => {
          throw new Errors.ValidationError('The results could not be converted to instances of `' + this.getTableName() + '`\nDetailed error: ' + err.message, err.errors);
        });
    }

    return this._parseGrouped(data);
  }

  /*
  * Implement an interface similar to events.EventEmitter
  */
  docAddListener(eventKey, listener) {
    let listeners = this._getModel()._listeners;
    if (listeners[eventKey] == null) { // eslint-disable-line
      listeners[eventKey] = [];
    }
    listeners[eventKey].push({
      once: false,
      listener: listener
    });
  }

  docOnce(eventKey, listener) {
    let listeners = this._getModel()._listeners;
    if (listeners[eventKey] == null) { // eslint-disable-line
      listeners[eventKey] = [];
    }
    listeners[eventKey].push({
      once: true,
      listener: listener
    });
  }

  docListeners(eventKey, raw) {
    if (eventKey == null) { // eslint-disable-line
      return this._getModel()._listeners;
    }

    raw = raw || true;
    if (raw === true) {
      return this._getModel()._listeners[eventKey];
    }

    return this._getModel()._listeners[eventKey]
      .map(fn => fn.listener);
  }

  docSetMaxListeners(n) {
    this._getModel()._maxListeners = n;
  }

  docRemoveListener(ev, listener) {
    if (Array.isArray(this._getModel()._listeners[ev])) {
      for (let i = 0, ii = this._getModel()._listeners[ev].length; i < ii; ++i) {
        if (this._getModel()._listeners[ev][i] === listener) {
          this._getModel()._listeners[ev].splice(i, 1);
          break;
        }
      }
    }
  }

  docRemoveAllListeners(ev) {
    if (ev === undefined) {
      delete this._getModel()._listeners[ev];
    } else {
      this._getModel()._listeners = {};
    }
  }

  pre(ev, fn) {
    if (typeof fn !== 'function') {
      throw new Errors.ThinkAgainError('Second argument to `pre` must be a function');
    }
    if (fn.length > 1) {
      throw new Errors.ThinkAgainError('Second argument to `pre` must be a function with at most one argument.');
    }
    if (Array.isArray(this._pre[ev]) === false) {
      throw new Errors.ThinkAgainError('No pre-hook available for the event `' + ev + '`.');
    }
    this._getModel()._async[ev] = this._getModel()._async[ev] || (fn.length === 1);
    this._getModel()._pre[ev].push(fn);
  }

  post(ev, fn) {
    if (typeof fn !== 'function') {
      throw new Errors.ThinkAgainError('Second argument to `pre` must be a function');
    }
    if (fn.length > 1) {
      throw new Errors.ThinkAgainError('Second argument to `pre` must be a function with at most one argument.');
    }
    if (Array.isArray(this._post[ev]) === false) {
      throw new Errors.ThinkAgainError('No post-hook available for the event `' + ev + '`.');
    }
    this._getModel()._async[ev] = this._getModel()._async[ev] || (fn.length === 1);
    this._getModel()._post[ev].push(fn);
  }
}

// aliases
Model.prototype.docOn = Model.prototype.docAddListener;

// Import rethinkdbdash methods
let Term = require('rethinkdbdash')({pool: false}).expr(1).__proto__; // eslint-disable-line
util.loopKeys(Term, (term, key) => {
  if (!Term.hasOwnProperty(key)) return;
  if (key === 'run' || key[0] === '_') return;

  switch (key) {
  case 'orderBy':
    Model.prototype[key] = function() {
      let query = new Query(this);
      if ((arguments.length === 1)
        && (typeof arguments[0] === 'string')
        && (this._getModel()._indexes[arguments[0]] === true)) {
        query = query[key]({index: arguments[0]});
        return query;
      }

      query = query[key].apply(query, arguments);
      return query;
    };
    break;
  case 'filter':
    Model.prototype[key] = function() {
      let query = new Query(this);
      if ((arguments.length === 1)
        && (util.isPlainObject(arguments[0]))) {
        // Optimize a filter with an object
        // We replace the first key that match an index name
        let filter = arguments[0];

        let keys = Object.keys(filter).sort(); // Lexicographical order
        for (let i = 0, ii = keys.length; i < ii; ++i) {
          let index = keys[i];

          if (this._getModel()._indexes[index] === true) { // Index found
            query = query.getAll(filter[index], {index: index});
            delete filter[index];
            break;
          }
        }
      }

      query = query[key].apply(query, arguments);
      return query;
    };
    break;
  case 'get':
      // Make a copy of `get` into `_get`
    Model.prototype._get = function() {
      let query = new Query(this);
      query = query._get.apply(query, arguments);
      return query;
    };
  default: // eslint-disable-line
    Model.prototype[key] = function() {
      let query = new Query(this);
      query = query[key].apply(query, arguments);
      return query;
    };
  }
});

module.exports = Model;