mongoose-elasticsearch-xp
Version:
A mongoose plugin that indexes models into Elasticsearch 2 / 5 (an alternative to mongoosastic)
656 lines (605 loc) • 16.8 kB
JavaScript
;
const mongoose = require('mongoose');
const generateMapping = require('./mapping').generate;
const client = require('./client');
const utils = require('./utils');
const Bulker = require('./bulker');
module.exports = function(schema, options, version = 5) {
// clone main level of options (does not clone deeper)
options = utils.highClone(options);
const cachedMappings = new Map();
let generateType;
if (typeof options.type === 'function') {
generateType = options.type;
}
/**
* Retrieve model options to ElasticSearch
* static function
* returns {Object}
*/
function esOptions() {
if (!options.index) {
options.index = this.collection.name;
}
if (generateType) {
options.type = generateType(this.modelName || this.constructor.modelName);
} else if (!options.type) {
options.type = utils.lcFirst(
this.modelName || this.constructor.modelName
);
}
if (!options.index || !options.type) {
throw new Error(
options.index
? 'Missing model name to build ES type'
: 'Missing collection name to build ES index'
);
}
if (!options.client) {
options.client = client(options);
}
if (options.bulk) {
options.bulker = new Bulker(options.client, options.bulk);
}
if (cachedMappings.has(this.schema)) {
options.mapping = cachedMappings.get(this.schema);
} else {
options.mapping = Object.freeze({
properties: generateMapping(this.schema, version),
});
cachedMappings.set(this.schema, options.mapping);
}
return utils.highClone(options);
}
schema.statics.esOptions = esOptions;
schema.statics.esCreateMapping = createMappingWithVersion(version);
schema.statics.esRefresh = refresh;
schema.statics.esSearch = search;
schema.statics.esSynchronize = synchronize;
schema.statics.esCount = count;
schema.methods.esOptions = esOptions;
schema.methods.esIndex = indexDoc;
schema.methods.esUnset = unsetFields;
schema.methods.esRemove = removeDoc;
if (!options.onlyOnDemandIndexing) {
schema.pre('save', preSave);
schema.post('save', postSave);
schema.post('findOneAndUpdate', postSave);
schema.post('remove', postRemove);
schema.post('findOneAndRemove', postRemove);
}
};
module.exports.v2 = function(schema, options) {
return module.exports(schema, options, 2);
};
module.exports.v5 = function(schema, options) {
return module.exports(schema, options, 5);
};
module.exports.v6 = function(schema, options) {
return module.exports(schema, options, 6);
};
module.exports.v7 = function(schema, options) {
return module.exports(schema, options, 7);
};
/**
* Wraps the model wrapping function with the version number
* @param {String} version
* @return {Function}
*/
function createMappingWithVersion(version) {
return function(settings, callback) {
return createMapping.call(this, settings, callback, version);
};
}
/**
* Map the model on ElasticSearch
* static function
* @param {Object} [settings]
* @param {Function} [callback]
* @param {String} [version] ElasticSearch version
* @returns {Promise|undefined}
*/
function createMapping(settings, callback, version) {
if (typeof settings === 'function') {
callback = settings;
settings = null;
}
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
settings = settings || esOptions.mappingSettings || {};
const mapping = {};
mapping[esOptions.type] = esOptions.mapping;
esOptions.client.indices.exists(
{ index: esOptions.index },
(err, exists) => {
if (err) {
return reject(err);
}
if (exists) {
const putMappingOpts = {
index: esOptions.index,
type: esOptions.type,
body: mapping,
};
if (version === 7) {
putMappingOpts.include_type_name = true;
}
return esOptions.client.indices.putMapping(
putMappingOpts,
(err, result) => (err ? reject(err) : resolve(result))
);
}
const createIndexOpts = {
index: esOptions.index,
body: settings,
};
if (version === 7) {
// Keep shards settings like the previous versions
if (!createIndexOpts.body.settings) {
createIndexOpts.body = { settings: {} };
}
createIndexOpts.body.settings.number_of_shards =
settings.number_of_shards || 5;
}
return esOptions.client.indices.create(createIndexOpts, err => {
if (err) {
reject(err);
return;
}
const putMappingOpts = {
index: esOptions.index,
type: esOptions.type,
body: mapping,
};
if (version === 7) {
putMappingOpts.include_type_name = true;
}
esOptions.client.indices.putMapping(putMappingOpts, (err, result) =>
err ? reject(err) : resolve(result)
);
});
}
);
});
}
/**
* Explicitly refresh the model index on ElasticSearch
* static function
* @param {Object} [options]
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function refresh(options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
const refreshDelay =
options.refreshDelay === false
? 0
: options.refreshDelay || esOptions.refreshDelay;
esOptions.client.indices.refresh(
{
index: esOptions.index,
},
(err, result) => {
setTimeout(() => (err ? reject(err) : resolve(result)), refreshDelay);
}
);
});
}
/**
* Perform a count query on ElasticSearch
* static function
* @param {Object|string} query
* @param {Object} [options]
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function count(query, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
query = query || {};
options = options || {};
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
const countOnly =
options.countOnly === false
? false
: options.countOnly || esOptions.countOnly;
const params = {
index: esOptions.index,
type: esOptions.type,
};
if (typeof query === 'string') {
params.q = query;
} else {
params.body = query.query ? query : { query };
}
esOptions.client.count(params, (err, result) => {
if (err) {
reject(err);
} else {
resolve(countOnly ? result.count : result);
}
});
});
}
/**
* Perform a search query on ElasticSearch
* static function
* @param {Object|string} query
* @param {Object} [options]
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function search(query, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
query = query || {};
options = options || {};
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
const hydrate =
options.hydrate === false ? false : options.hydrate || esOptions.hydrate;
const idsOnly =
options.idsOnly === false ? false : options.idsOnly || esOptions.idsOnly;
const params = {
index: esOptions.index,
type: esOptions.type,
};
if (typeof query === 'string') {
params.q = query;
} else {
params.body = query.query ? query : { query };
}
if (hydrate) {
params._source = false;
}
esOptions.client.search(params, (err, result) => {
if (err) {
reject(err);
return;
}
if (!hydrate && !idsOnly) {
resolve(result);
return;
}
const isObjectId = utils.getType(self.schema.paths._id) === 'objectid';
const ids = result.hits.hits.map(hit =>
isObjectId ? mongoose.Types.ObjectId(hit._id) : hit._id
);
if (idsOnly) {
resolve(ids);
return;
}
const select = hydrate.select || null;
const opts = hydrate.options || null;
const docsOnly = hydrate.docsOnly || false;
if (!result.hits.total) {
resolve(docsOnly ? [] : result);
return;
}
let query = self.find({ _id: { $in: ids } }, select, opts);
if (hydrate.populate) {
query = query.populate(hydrate.populate);
}
query.exec((err, users) => {
if (err) {
return reject(err);
}
const userByIds = {};
users.forEach(user => {
userByIds[user._id] = user;
});
if (docsOnly) {
result = ids.map(id => userByIds[id]);
} else {
result.hits.hits.forEach(hit => {
hit.doc = userByIds[hit._id];
});
}
return resolve(result);
});
});
});
}
/**
* Synchronize the collection with ElasticSearch
* static function
* @param {Object} [conditions]
* @param {String|Object} [projection]
* @param {Object} [options]
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function synchronize(conditions, projection, options, callback) {
if (typeof conditions === 'function') {
callback = conditions;
conditions = {};
projection = null;
options = null;
} else if (typeof projection === 'function') {
callback = projection;
projection = null;
options = null;
} else if (typeof options === 'function') {
callback = options;
options = null;
}
const model = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = model.esOptions();
const batch =
esOptions.bulk && esOptions.bulk.batch ? esOptions.bulk.batch : 50;
let query;
if (conditions instanceof mongoose.Query) {
query = conditions.lean().batchSize(batch);
} else {
query = model
.find(conditions || {}, projection, options)
.lean()
.batchSize(batch);
}
// use query.stream() as a fallback for old mongoose versions
const stream = query.cursor ? query.cursor() : query.stream();
const bulker = esOptions.bulker || new Bulker(esOptions.client);
let streamClosed = false;
function finalize() {
bulker.removeListener('error', onError);
bulker.removeListener('sent', onSent);
esOptions.client.indices.refresh(
{ index: esOptions.index },
(err, result) => (err ? reject(err) : resolve(result))
);
}
function onError(err) {
model.emit('es-bulk-error', err);
if (streamClosed) {
finalize();
} else {
stream.resume();
}
}
function onSent(len) {
model.emit('es-bulk-sent', len);
if (streamClosed) {
finalize();
} else {
stream.resume();
}
}
bulker.on('error', onError);
bulker.on('sent', onSent);
stream.on('data', doc => {
stream.pause();
let sending;
if (!esOptions.filter || esOptions.filter(doc)) {
sending = bulker.push(
{
index: {
_index: esOptions.index,
_type: esOptions.type,
_id: doc._id.toString(),
},
},
utils.serialize(doc, esOptions.mapping)
);
model.emit('es-bulk-data', doc);
} else {
model.emit('es-bulk-filtered', doc);
}
if (!sending) {
stream.resume();
}
});
stream.on('end', () => {
streamClosed = true;
if (bulker.filled()) {
bulker.flush();
} else if (!bulker.isFlushing()) {
finalize();
}
});
});
}
/**
* Index the current document on ElasticSearch
* document function
* @param {Boolean|Object} [update] default false
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function indexDoc(update, callback) {
const self = this;
if (typeof update === 'function') {
callback = update;
update = false;
}
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
let body = utils.serialize(self, esOptions.mapping);
if (typeof esOptions.transform === 'function') {
const transformedBody = esOptions.transform(body);
if (transformedBody) {
body = transformedBody;
}
}
if (update && update.unset) {
(typeof update.unset === 'string'
? [update.unset]
: update.unset
).forEach(field => {
body[field] = null;
});
}
_indexDoc(self._id, body, esOptions, resolve, reject, update);
});
}
/**
* Update or Index a document, when updating, retry as index when getting a 404 error
* @param {ObjectId|String} id
* @param {Object} body
* @param {Object} esOptions
* @param {Function} resolve
* @param {Function} reject
* @param {Boolean} [update] default false
* @private
*/
function _indexDoc(id, body, esOptions, resolve, reject, update) {
esOptions.client[update ? 'update' : 'index'](
{
index: esOptions.index,
type: esOptions.type,
id: id.toString(),
body: update ? { doc: body } : body,
},
(err, result) => {
if (update && err && err.status === 404) {
_indexDoc(id, body, esOptions, resolve, reject);
} else if (err) {
reject(err);
} else {
resolve(result);
}
}
);
}
/**
* Unset some fields from the current document
* @param {String|Array} fields to unset
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function unsetFields(fields, callback) {
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
let body;
if (typeof fields === 'string') {
fields = [fields];
}
if (esOptions.script) {
body = {
script: fields.map(field => `ctx._source.remove("${field}")`).join(';'),
};
} else {
body = { doc: {} };
fields.forEach(field => {
body.doc[field] = null;
});
}
esOptions.client.update(
{
index: esOptions.index,
type: esOptions.type,
id: self._id.toString(),
body,
},
(err, result) => (err ? reject(err) : resolve(result))
);
});
}
/**
* Remove the current document from ElasticSearch
* document function
* @param {Function} [callback]
* @returns {Promise|undefined}
*/
function removeDoc(callback) {
const self = this;
return utils.run(callback, (resolve, reject) => {
const esOptions = self.esOptions();
esOptions.client.delete(
{
index: esOptions.index,
type: esOptions.type,
id: self._id.toString(),
},
err => {
if (err) {
reject(err);
} else {
resolve();
}
}
);
});
}
/**
* Pre save document handler
* internal
* @param {Function} next
*/
function preSave(next) {
this._mexp = {
wasNew: this.isNew,
};
if (!this.isNew) {
this._mexp.unset = utils.getUndefineds(this, this.esOptions().mapping);
}
next();
}
/**
* Post save document handler
* internal
* @param {Object} doc
*/
function postSave(doc) {
if (doc && doc.esOptions) {
const data = doc._mexp || {};
const esOptions = doc.esOptions();
delete doc._mexp;
if (!esOptions.filter || esOptions.filter(doc)) {
doc
.esIndex(data.wasNew ? false : { unset: data.unset })
.then(res => {
if (esOptions.script && data.unset && data.unset.length) {
return doc.esUnset(data.unset);
}
return res;
})
.then(res => {
doc.emit('es-indexed', undefined, res);
doc.constructor.emit('es-indexed', undefined, res);
})
.catch(err => {
doc.emit('es-indexed', err);
doc.constructor.emit('es-indexed', err);
});
} else {
doc.emit('es-filtered');
doc.constructor.emit('es-filtered');
if (!data.wasNew) {
doc.esRemove((err, res) => {
doc.emit('es-removed', err, res);
doc.constructor.emit('es-removed', err, res);
});
}
}
}
}
/**
* Post remove document handler
* internal
* @param {Object} doc
*/
function postRemove(doc) {
if (doc && doc.esOptions) {
doc.esRemove((err, res) => {
doc.emit('es-removed', err, res);
doc.constructor.emit('es-removed', err, res);
});
}
}