├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENCE ├── README.md ├── index.js ├── package.json └── test ├── index.js └── testModel.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.9" 5 | - "0.10" 6 | 7 | services: 8 | - mongodb 9 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Pavel Vlasov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mongoose full-text search plugin 2 | 3 | Simple mongoose plugin for full text search. 4 | Uses [natural](https://github.com/NaturalNode/natural) stemming and distance algorithms. 5 | 6 | ## Example 7 | ``` js 8 | var mongoose = require('mongoose'), 9 | searchPlugin = require('mongoose-search-plugin'); 10 | 11 | var Schema = mongoose.Schema({ 12 | title: String, 13 | description: String, 14 | tags: [String] 15 | }); 16 | 17 | Schema.plugin(searchPlugin, { 18 | fields: ['title', 'description', 'tags'] 19 | }); 20 | 21 | var Model = mongoose.model('MySearchModel', Schema); 22 | Model.search('some query', {title: 1}, { 23 | conditions: {title: {$exists: true}}, 24 | sort: {title: 1}, 25 | limit: 10 26 | }, function(err, data) { 27 | // array of found results 28 | console.log(data.results); 29 | // count of all matching objects 30 | console.log(data.totalCount); 31 | }); 32 | ``` 33 | 34 | ## Installation 35 | ``` bash 36 | $ npm install mongoose-search-plugin --save 37 | ``` 38 | 39 | ## Usage 40 | 41 | ### Initialization 42 | `plugin` accepts options argument with following format: 43 | ``` js 44 | var options = { 45 | keywordsPath: '_keywords', // path for keywords, `_keywords` as default 46 | relevancePath: '_relevance', // path for relevance number, '_relevance' as default 47 | fields: [], // array of fields to use as keywords (can be String or [String] types), 48 | stemmer: 'PorterStemmer', // natural stemmer, PorterStemmer as default 49 | distance: 'JaroWinklerDistance' // distance algorithm, JaroWinklerDistance as default 50 | }; 51 | Schema.plugin(searchPlugin, options); 52 | ``` 53 | 54 | ### Search 55 | `Model.search(query, fields, options, callback)` 56 | 57 | Options are optional. The `fields` parameter can be an empty object to return all fields. 58 | 59 | Method will return object of the following format: 60 | ``` js 61 | { 62 | results: [], // array of results objects 63 | totalCount: 0 // number of objects, that matched criteries 64 | } 65 | ``` 66 | Options has following format: 67 | ```js 68 | { 69 | conditions: {}, // criteria for query 70 | sort: {} // sorting parameters 71 | populate: [{path: '', fields: ''}], // array of paths to populate 72 | ... and other options of Model.find method 73 | } 74 | ``` 75 | By default results sorts by relevance field, that defined in `relevancePath` 76 | plugin option. 77 | 78 | ### Set keywords 79 | If You start using plugin on existing database to initialize keywords field in object 80 | use `setKeywords` method. 81 | ``` js 82 | Model.setKeywords(function(err) { 83 | // ... 84 | }); 85 | ``` 86 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mongoose = require('mongoose'), 4 | natural = require('natural'), 5 | _ = require('underscore'); 6 | 7 | module.exports = function(schema, options) { 8 | var stemmer = natural[options.stemmer || 'PorterStemmer'], 9 | distance = natural[options.distance || 'JaroWinklerDistance'], 10 | fields = options.fields, 11 | keywordsPath = options.keywordsPath || '_keywords', 12 | relevancePath = options.relevancePath || '_relevance'; 13 | 14 | // init keywords field 15 | var schemaMixin = {}; 16 | schemaMixin[keywordsPath] = [String]; 17 | schemaMixin[relevancePath] = Number; 18 | schema.add(schemaMixin); 19 | schema.path(keywordsPath).index(true); 20 | 21 | // search method 22 | schema.statics.search = function(query, fields, options, callback) { 23 | if (arguments.length === 2) { 24 | callback = fields; 25 | options = {}; 26 | } else { 27 | if (arguments.length === 3) { 28 | callback = options; 29 | options = {}; 30 | } else { 31 | options = options || {}; 32 | } 33 | } 34 | 35 | var self = this; 36 | var tokens = _(stemmer.tokenizeAndStem(query)).unique(), 37 | conditions = options.conditions || {}, 38 | outFields = {_id: 1}, 39 | findOptions = _(options).pick('sort'); 40 | 41 | conditions[keywordsPath] = {$in: tokens}; 42 | outFields[keywordsPath] = 1; 43 | 44 | mongoose.Model.find.call(this, conditions, outFields, findOptions, 45 | function(err, docs) { 46 | if (err) return callback(err); 47 | 48 | var totalCount = docs.length, 49 | processMethod = options.sort ? 'map' : 'sortBy'; 50 | 51 | // count relevance and sort results if sort option not defined 52 | docs = _(docs)[processMethod](function(doc) { 53 | var relevance = processRelevance(tokens, doc.get(keywordsPath)); 54 | doc.set(relevancePath, relevance); 55 | return processMethod === 'map' ? doc : -relevance; 56 | }); 57 | 58 | // slice results and find full objects by ids 59 | if (options.limit || options.skip) { 60 | options.skip = options.skip || 0; 61 | options.limit = options.limit || (docs.length - options.skip); 62 | docs = docs.slice(options.skip || 0, options.skip + options.limit); 63 | } 64 | 65 | var docsHash = _(docs).indexBy('_id'), 66 | findConditions = _({ 67 | _id: {$in: _(docs).pluck('_id')} 68 | }).extend(options.conditions); 69 | 70 | var cursor = mongoose.Model.find 71 | .call(self, findConditions, fields, findOptions); 72 | 73 | // populate 74 | if (options.populate) { 75 | options.populate.forEach(function(object) { 76 | cursor.populate(object.path, object.fields); 77 | }); 78 | } 79 | 80 | cursor.exec(function(err, docs) { 81 | if (err) return callback(err); 82 | 83 | // sort result docs 84 | callback(null, { 85 | results: _(docs)[processMethod](function(doc) { 86 | var relevance = docsHash[doc._id].get(relevancePath); 87 | doc.set(relevancePath, relevance); 88 | return processMethod === 'map' ? doc : -relevance; 89 | }), 90 | totalCount: totalCount 91 | }); 92 | }); 93 | }); 94 | 95 | function processRelevance(queryTokens, resultTokens) { 96 | var relevance = 0; 97 | 98 | queryTokens.forEach(function(token) { 99 | relevance += tokenRelevance(token, resultTokens); 100 | }); 101 | return relevance; 102 | } 103 | 104 | function tokenRelevance(token, resultTokens) { 105 | var relevanceThreshold = 0.5, 106 | result = 0; 107 | 108 | resultTokens.forEach(function(rToken) { 109 | var relevance = distance(token, rToken); 110 | if (relevance > relevanceThreshold) { 111 | result += relevance; 112 | } 113 | }); 114 | 115 | return result; 116 | } 117 | }; 118 | 119 | // set keywords for all docs in db 120 | schema.statics.setKeywords = function(callback) { 121 | callback = _(callback).isFunction() ? callback : function() {}; 122 | 123 | mongoose.Model.find.call(this, {}, function(err, docs) { 124 | if (err) return callback(err); 125 | 126 | if (docs.length) { 127 | var done = _.after(docs.length, function() { 128 | callback(); 129 | }); 130 | docs.forEach(function(doc) { 131 | doc.updateKeywords(); 132 | 133 | doc.save(function(err) { 134 | if (err) console.log('[mongoose search plugin err] ', err, err.stack); 135 | done(); 136 | }); 137 | }); 138 | } else { 139 | callback(); 140 | } 141 | }); 142 | }; 143 | 144 | schema.methods.updateKeywords = function() { 145 | this.set(keywordsPath, this.processKeywords()); 146 | }; 147 | 148 | schema.methods.processKeywords = function() { 149 | var self = this; 150 | return _(stemmer.tokenizeAndStem(fields.map(function(field) { 151 | var val = self.get(field); 152 | 153 | if (_(val).isString()) { 154 | return val; 155 | } 156 | if (_(val).isArray()) { 157 | return val.join(' '); 158 | } 159 | 160 | return ''; 161 | }).join(' '))).unique(); 162 | }; 163 | 164 | schema.pre('save', function(next) { 165 | var self = this; 166 | 167 | var isChanged = this.isNew || fields.some(function (field) { 168 | return self.isModified(field); 169 | }); 170 | 171 | if (isChanged) this.updateKeywords(); 172 | next(); 173 | }); 174 | }; 175 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-search-plugin", 3 | "version": "0.1.2", 4 | "description": "mongoose full-text search plugin", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/freakycue/mongoose-search-plugin" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "mocha test/index.js" 12 | }, 13 | "keywords": [ 14 | "mongoose", 15 | "search", 16 | "plugin", 17 | "full", 18 | "text" 19 | ], 20 | "author": "Pavel Vlasov ", 21 | "license": "MIT", 22 | "dependencies": { 23 | "natural": "~0.1.25", 24 | "underscore": "~1.5.2" 25 | }, 26 | "devDependencies": { 27 | "mocha": "~1.17.1", 28 | "expect.js": "~0.2.0", 29 | "mongoose": "~3.8.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('expect.js'), 4 | mongoose = require('mongoose'), 5 | _ = require('underscore'); 6 | 7 | require('./testModel'); 8 | 9 | var TestModel = mongoose.model('TestModel'), 10 | EmbeddedTestModel = mongoose.model('EmbeddedTestModel'); 11 | 12 | describe('search plugin', function() { 13 | var raw = { 14 | title: 'the object to search.', 15 | description: 'You have to search this object.', 16 | tags: ['object', 'to', 'search', 'challenges'] 17 | }; 18 | 19 | it('connect to db', function() { 20 | mongoose.connect('mongodb://127.0.0.1:27017/test'); 21 | }); 22 | 23 | it('clear test collection', function(done) { 24 | TestModel.remove({}, done); 25 | }); 26 | 27 | it('save object', function(done) { 28 | var obj = new TestModel(raw); 29 | obj.save(done); 30 | }); 31 | 32 | it('save another object', function(done) { 33 | var obj = new TestModel({ 34 | title: 'another one object', 35 | description: 'You have to find this one too.', 36 | tags: ['another', 'object', 'to', 'find'] 37 | }); 38 | obj.save(done); 39 | }); 40 | 41 | it('search should return both objects', function(done) { 42 | TestModel.search('object', null, null, function(err, data) { 43 | expect(err).not.to.be.ok(); 44 | expect(data.results).to.be.ok(); 45 | expect(data.results.length).to.be.ok(); 46 | expect(data.results.length).to.equal(2); 47 | 48 | done(err); 49 | }); 50 | }); 51 | 52 | it('search should return first object', function(done) { 53 | TestModel.search('search', null, null, function(err, data) { 54 | expect(err).not.to.be.ok(); 55 | expect(data.results).to.be.ok(); 56 | expect(data.results.length).to.be.ok(); 57 | expect(data.results.length).to.equal(1); 58 | 59 | done(err); 60 | }); 61 | }); 62 | 63 | it('search should return second object', function(done) { 64 | TestModel.search('find', null, null, function(err, data) { 65 | expect(err).not.to.be.ok(); 66 | expect(data.results).to.be.ok(); 67 | expect(data.results.length).to.be.ok(); 68 | expect(data.results.length).to.equal(1); 69 | 70 | done(err); 71 | }); 72 | }); 73 | 74 | it('search should return no objects', function(done) { 75 | TestModel.search('unexpected tokens', null, null, function(err, data) { 76 | expect(err).not.to.be.ok(); 77 | expect(data.results).to.be.ok(); 78 | expect(data.results.length).to.equal(0); 79 | 80 | done(err); 81 | }); 82 | }); 83 | 84 | it('generate more objects', function(done) { 85 | var len = 4, inserted = 0; 86 | for(var i = 0; i <= len; i++) { 87 | var obj = new TestModel(raw); 88 | obj.save(function(err) { 89 | expect(err).not.to.be.ok(); 90 | 91 | if (++inserted === len) done(); 92 | }); 93 | } 94 | }); 95 | 96 | var allCount; 97 | it('search all objects with outfields', function(done) { 98 | TestModel.search('object', {title: 0}, function(err, data) { 99 | expect(err).not.to.be.ok(); 100 | expect(data.results[0].title).not.to.be.ok(); 101 | 102 | allCount = data.results.length; 103 | done(err); 104 | }); 105 | }); 106 | 107 | it('search with limit option', function(done) { 108 | TestModel.search('object', null, {limit: 3}, function(err, data) { 109 | expect(err).not.to.be.ok(); 110 | expect(data.results.length).to.equal(3); 111 | expect(data.totalCount).to.equal(allCount) 112 | 113 | done(err); 114 | }); 115 | }); 116 | 117 | it('search with skip option', function(done) { 118 | TestModel.search('object', null, {skip: 2}, function(err, data) { 119 | expect(err).not.to.be.ok(); 120 | expect(data.results.length).to.equal(allCount - 2); 121 | expect(data.totalCount).to.equal(allCount) 122 | 123 | done(err); 124 | }); 125 | }); 126 | 127 | it('search with limit and skip option', function(done) { 128 | TestModel.search('object', null, {skip: 2, limit: 2}, function(err, data) { 129 | expect(err).not.to.be.ok(); 130 | expect(data.results.length).to.equal(2); 131 | expect(data.totalCount).to.equal(allCount) 132 | 133 | done(err); 134 | }); 135 | }); 136 | 137 | it('search with sort option', function(done) { 138 | TestModel.search('object', null, {sort: {index: 1}}, function(err, data) { 139 | var min = data.results[0].index; 140 | expect(_(data.results).all(function(item) { 141 | var res = item.index >= min; 142 | min = item.index; 143 | return res; 144 | })).to.be.ok(); 145 | done(err); 146 | }); 147 | }); 148 | 149 | it('search with conditions option', function(done) { 150 | TestModel.search('object', null, { 151 | conditions: {index: {$gt: 50}} 152 | }, function(err, data) { 153 | expect(_(data.results).all(function(item) { 154 | return item.index > 50; 155 | })).to.be.ok(); 156 | done(err); 157 | }); 158 | }); 159 | 160 | var embedded; 161 | it('create document embedded document', function(done) { 162 | embedded = new EmbeddedTestModel({ 163 | title: 'embedded', 164 | description: 'embedded' 165 | }); 166 | embedded.save(done); 167 | }); 168 | 169 | it('create document with ref option', function(done) { 170 | var obj = new TestModel({ 171 | title: 'with embedded', 172 | description: 'with embedded', 173 | tags: ['with', 'embedded'], 174 | embedded: embedded._id 175 | }); 176 | obj.save(done); 177 | }); 178 | 179 | it('search document with populate option', function(done) { 180 | TestModel.search('embedded', null, { 181 | populate: [{path: 'embedded'}] 182 | }, function(err, data) { 183 | expect(data.results.length).equal(1); 184 | expect(data.results[0].embedded._id).to.be.ok(); 185 | done(err); 186 | }); 187 | }); 188 | 189 | it('search document with populate option and fields options', 190 | function(done) { 191 | TestModel.search('embedded', null, { 192 | populate: [{path: 'embedded', fields: {title: 0}}] 193 | }, function(err, data) { 194 | expect(data.results.length).equal(1); 195 | expect(data.results[0].embedded._id).to.be.ok(); 196 | expect(data.results[0].embedded.title).not.to.be.ok(); 197 | done(err); 198 | }); 199 | }); 200 | 201 | it('test setKeywords method', function(done) { 202 | TestModel.setKeywords(done); 203 | }); 204 | 205 | it('clear test collection', function(done) { 206 | TestModel.remove({}, done); 207 | }); 208 | 209 | it('setKeywords on empty collection', function(done) { 210 | TestModel.setKeywords(done); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /test/testModel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mongoose = require('mongoose'), 4 | plugin = require('../index'), 5 | _ = require('underscore'); 6 | 7 | var TestModelSchema = mongoose.Schema({ 8 | title: {type: String, required: true}, 9 | description: {type: String, required: true}, 10 | tags: {type: [String], required: true}, 11 | embedded: {type: mongoose.Schema.ObjectId, ref: 'EmbeddedTestModel'}, 12 | index: {type: Number, 'default': function() { 13 | return _.random(0, 100); 14 | }} 15 | }); 16 | 17 | TestModelSchema.plugin(plugin, { 18 | fields: ['title', 'description', 'tags'] 19 | }); 20 | 21 | mongoose.model('TestModel', TestModelSchema); 22 | 23 | var EmbeddedTestModelSchema = mongoose.Schema({ 24 | title: {type: String, required: true}, 25 | description: {type: String, required: true} 26 | }); 27 | 28 | mongoose.model('EmbeddedTestModel', EmbeddedTestModelSchema); 29 | --------------------------------------------------------------------------------