diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000..719c2998 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,4 @@ +languages: + JavaScript: true +# exclude_paths: +# - "foo/bar.rb" \ No newline at end of file diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index fe66af08..00000000 --- a/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -# http://editorconfig.org -root = true - -[*] -indent_style = space -indent_size = 4 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false \ No newline at end of file diff --git a/.jshintrc b/.jshintrc index f41eb2b6..3a886033 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,29 +1,32 @@ { - "node": true, - "esnext": true, - "bitwise": true, - "camelcase": true, - "curly": true, - "eqeqeq": true, - "immed": true, - "indent": 4, - "latedef": "nofunc", - "newcap": true, - "noarg": true, - "quotmark": "single", - "regexp": true, - "undef": true, - "unused": true, - "strict": false, - "trailing": true, - "smarttabs": true, - "white": false, - "loopfunc": true, - "globals": { - "it": true, - "describe": true, - "before": true, - "after": true, - "exports": true - } + "node": true, + "esnext": true, + "bitwise": true, + "camelcase": true, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": "nofunc", + "newcap": true, + "noarg": true, + "quotmark": "single", + "regexp": true, + "undef": true, + "unused": true, + "strict": false, + "trailing": true, + "smarttabs": true, + "white": false, + "loopfunc": true, + "expr": true, + "globals": { + "it": true, + "describe": true, + "before": true, + "beforeEach": true, + "after": true, + "afterEach": true, + "exports": true + } } \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 0420fd05..72b58790 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,6 @@ language: node_js +services: mongodb node_js: - - "0.11" - "0.10" - - "0.8" -before_install: - - "npm install -g mocha" -services: - - mongodb + - "node" + - "iojs" \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index f9a0bbe3..00000000 --- a/Gruntfile.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -module.exports = function (grunt) { - - // Project configuration. - grunt.initConfig({ - pkg: grunt.file.readJSON('package.json'), - release: {}, - jshint: { - options: { - jshintrc: '.jshintrc' - }, - lib: ['lib/**/*.js', 'Gruntfile.js'], - test: 'test/**/*.js', - example: 'example/*.js' - }, - simplemocha: { - options: { - - }, - all: { - src: ['test/**/*.js'] - } - }, - watch: { - scripts: { - files: '**/*.js', - tasks: ['jshint', 'express:dev'], - options: { - spawn: false - } - }, - tests: { - files: '**/*.js', - tasks: ['simplemocha'], - options: { - spawn: true - } - } - }, - express: { - options: { - // Override defaults here - }, - dev: { - options: { - script: 'example/index.js' - } - } - } - }); - - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-release'); - grunt.loadNpmTasks('grunt-simple-mocha'); - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-express-server'); - - grunt.registerTask('test', ['jshint', 'simplemocha']); - grunt.registerTask('default', ['jshint', 'simplemocha', 'express:dev', 'watch']); - -}; \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5497e899..c6c231be 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Glavin Wiechert +Copyright (c) 2015 FeathersJS Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index e0c07c2c..1ecf692e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ -[feathers-mongoose](https://github.com/feathersjs/feathers-mongoose) [![NPM version](https://badge.fury.io/js/feathers-mongoose.png)](http://badge.fury.io/js/feathers-mongoose) -========================= +feathers-mongoose +================ + +[![NPM](https://nodei.co/npm/feathers-mongoose.png?downloads=true&stars=true)](https://nodei.co/npm/feathers-mongoose/) [![Build Status](https://travis-ci.org/feathersjs/feathers-mongoose.svg?branch=master)](https://travis-ci.org/feathersjs/feathers-mongoose) [![Code Climate](https://codeclimate.com/github/feathersjs/feathers-mongoose.png)](https://codeclimate.com/github/feathersjs/feathers-mongoose) -[![Dependency Status](https://david-dm.org/feathersjs/feathers-mongoose.svg)](https://david-dm.org/feathersjs/feathers-mongoose) -[![devDependency Status](https://david-dm.org/feathersjs/feathers-mongoose/dev-status.svg)](https://david-dm.org/feathersjs/feathers-mongoose#info=devDependencies) -[![Total views](https://sourcegraph.com/api/repos/github.com/feathersjs/feathers-mongoose/counters/views.png)](https://sourcegraph.com/github.com/feathersjs/feathers-mongoose) -[![Views in the last 24 hours](https://sourcegraph.com/api/repos/github.com/feathersjs/feathers-mongoose/counters/views-24h.png)](https://sourcegraph.com/github.com/feathersjs/feathers-mongoose) -[![NPM](https://nodei.co/npm/feathers-mongoose.png?downloads=true&stars=true)](https://nodei.co/npm/feathers-mongoose/) -> Easily create a [Mongoose](http://mongoosejs.com/) Service for [Featherjs](https://github.com/feathersjs). +> Create a [Mongoose](http://mongoosejs.com/) ORM wrapped service for [FeathersJS](https://github.com/feathersjs). ## Installation @@ -19,16 +16,278 @@ npm install feathers-mongoose --save ``` -See [Getting Started, in the docs/ directory](https://github.com/feathersjs/feathers-mongoose/tree/master/docs/Getting-Started.md). +## Getting Started + +Creating an Mongoose service is this simple: + +```js +var mongooseService = require('feathers-mongoose'); +app.use('todos', mongooseService('todo', todoSchema, options)); +``` + +See the [Mongoose Schema Guide](http://mongoosejs.com/docs/guide.html) for more information on defining your schema. + +### Complete Example + +Here's a complete example of a Feathers server with a `todos` mongoose-service. + +```js +// models/todo.js +var Todo = { + schema: { + title: {type: String, required: true}, + description: {type: String}, + dueDate: {type: Date, 'default': Date.now}, + complete: {type: Boolean, 'default': false, index: true} + }, + methods: { + isComplete: function(){ + return this.complete; + } + }, + statics: { + }, + virtuals: { + }, + indexes: [ + {'dueDate': -1, background: true} + ] +}; + +// server.js +var feathers = require('feathers'), + bodyParser = require('body-parser'), + mongooseService = require('feathers-mongoose'); + +// Create a feathers instance. +var app = feathers() + // Setup the public folder. + .use(feathers.static(__dirname + '/public')) + // Enable Socket.io + .configure(feathers.socketio()) + // Enable REST services + .configure(feathers.rest()) + // Turn on JSON parser for REST services + .use(bodyParser.json()) + // Turn on URL-encoded parser for REST services + .use(bodyParser.urlencoded({extended: true})) + +// Connect to the db, create and register a Feathers service. +app.use('todos', new mongooseService('todo', Todo)); + +// Start the server. +var port = 8080; +app.listen(port, function() { + console.log('Feathers server listening on port ' + port); +}); +``` + +You can run this example by using `node examples/basic` and going to [localhost:8080/todos](http://localhost:8080/todos). You should see an empty array. That's because you don't have any Todos yet but you now have full CRUD for your new todos service, including mongoose validations! + +## Options + +The following options can be passed when creating a new Mongoose service: + +**General options:** + +- `connectionString` - A MongoDB connection string + +**Connection options**: (when `connectionString` is not set) + +- `username` - MongoDB username +- `password` - MongoDB password +- `db` - The name of the database (default: `"feathers"`) +- `host` - The MongoDB host (default: `"localhost"`) +- `port` - The MongoDB port (default: `27017`) +- `reconnect` - Whether the connection should automatically reconnect (default: `true`) + +**Note:** By default, each service creates its own database connection. If you don't want this you can pass an existing mongoose connection via the `connection` property. + +## Mongoose Schemas + +The recommended way of defining and passing a model to a `feathers-mongoose` service is shown above. Using object literal syntax makes things easily testable without having to mock out existing mongoose functionality. + +With that said, you have two other options: + +### Passing a Mongoose Schema + +```js +// models/todo.js +var mongoose = require('mongoose'), + Schema = mongoose.Schema; + +var TodoSchema = Schema({ + title: {type: String, required: true}, + description: {type: String}, + dueDate: {type: Date, 'default': Date.now}, + complete: {type: Boolean, 'default': false, index: true} +}); + +TodoSchema.methods.isComplete = function() { + return this.complete; +} + +TodoSchema.index({'dueDate': -1, background: true}); + +module.exports = TodoSchema; +``` + +Then you can simply pass that to a mongoose service like so: + +```js +//server.js +app.use('todos', mongooseService('todo', TodoSchema)); +``` + +### Passing a Mongoose Model +Usually before you are able to actually use a mongoose schema you need to turn it into a model. `feathers-mongoose` does that for you but you can also pass a mongoose model explicitly. + +This style is not a whole lot different than above. Note the `mongoose.model()` call. + +```js +// models/todo.js +var mongoose = require('mongoose'), + Schema = mongoose.Schema; + +var TodoSchema = Schema({ + title: {type: String, required: true}, + description: {type: String}, + dueDate: {type: Date, 'default': Date.now}, + complete: {type: Boolean, 'default': false, index: true} +}); + +TodoSchema.methods.isComplete = function() { + return this.complete; +} + +TodoSchema.index({'dueDate': -1, background: true}); + +module.exports = mongoose.model('Todo', TodoSchema); +``` + +Then you can simply pass that to a mongoose service like so: + +```js +//server.js +app.use('todos', mongooseService('todo', TodoModel)); +``` + + +## Custom Validation + +TODO (EK): Add example with custom validations using `node-validator` or something. + +## Special Query Params +The `find` API allows the use of `$limit`, `$skip`, `$sort`, `$select`, `$populate` in the query. These special parameters can be passed directly inside the query object: + +```js +// Find all recipes that include salt, limit to 10, only include name field. +{"ingredients":"salt", "$limit":10, "$select":"name:1"} // JSON +GET /?ingredients=salt&%24limit=10&%24select=name%3A1 // HTTP +``` + +As a result of allowing these to be put directly into the query string, you won't want to use `$limit`, `$skip`, `$sort`, `$select`, or `$populate` as the name of fields in your document schema. + +### `$limit` + +`$limit` will return only the number of results you specify: + +``` +// Retrieves the first two records found where age is 37. +query: { + age: 37, + $limit: 2 +} +``` + + +### `$skip` + +`$skip` will skip the specified number of results: + +``` +// Retrieves all except the first two records found where age is 37. +query: { + age: 37, + $skip: 2 +} +``` + + +### `$sort` + +`$sort` will sort based on the object you provide: + +``` +// Retrieves all where age is 37, sorted ascending alphabetically by name. +query: { + age: 37, + $sort: {'name': 1} +} + +// Retrieves all where age is 37, sorted descending alphabetically by name. +query: { + age: 37, + $sort: {'name': -1} +} +``` + + +### `$select` +`$select` support in a query allows you to pick which fields to include or exclude in the results. **Note:** you can use the include syntax or the exclude syntax, not both together. See the section on [`Select`](http://mongoosejs.com/docs/api.html#query_Query-select) in the Mongoose docs. +``` +// Only retrieve name. +query: { + name: 'Alice', + $select: {'name': 1} +} + +// Retrieve everything except age. +query: { + name: 'Alice', + $select: {'age': 0} +} +``` + +### `$populate` +`$populate` support in a query allows you to populate an embedded document and return it in the results. See the section on [`Population`](http://mongoosejs.com/docs/populate.html) in the Mongoose docs. +``` +// Return people named "Alice" and her children. +query: { + name: 'Alice', + $populate: ['children'] +} +``` + + +## API + +`feathers-mongoose` services comply with the standard [FeathersJS API](http://feathersjs.com/docs). + +## Changelog +### 2.0.0 +- Consistency with other service adapters +- Compatibility with Feathers 1.0+ +- Adequate tests +- Autoreconnect by default when not passing a connection string +- Add special query params: + - $sort + - $skip + - $limit + - $select + - $populate -## Documentation +### 0.1.1 +- First working release -See the [docs/ directory](https://github.com/feathersjs/feathers-mongoose/tree/master/docs). +### 0.1.0 +- Initial release. ## License [MIT](LICENSE) -## Author +## Authors -[Glavin Wiechert](https://github.com/Glavin001) +- [Eric Kryski](http://erickryski.com) +- [Glavin Wiechert](https://github.com/Glavin001) diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index f65211f6..00000000 --- a/docs/API.md +++ /dev/null @@ -1,74 +0,0 @@ -# API - -All requests below are assumed that *your* service's `path` is `"/path"`. - -For instance, if you added your service with the following `path`: - -```javascript -app.use('/path', yourService) // <-- Register your custom Mongoose-Service with Feathers -``` - -Then the corresponding `GET` request to `find all` of the [documents](http://docs.mongodb.org/manual/core/document/) would be: - -``` -GET /path -``` - -**Important**: Make sure all requests have the `Content-Type` set to `application/json`. See [Feathersjs Issue](https://github.com/feathersjs/feathers/issues/40) for more information. - ------ - -## Finding [Documents](http://docs.mongodb.org/manual/core/document/) - -This query will return all documents at that `path`. - -``` -GET /path -``` - -### Advanced Querying - -You have access to the powerful query language behind Mongoose and MongoDB. - -See [Mongoose's find method](http://mongoosejs.com/docs/api.html#model_Model.find) for details of how it works behind the scenes. - -Example request with all of the optional query parameters: - -``` -GET /path?conditions={}&fields="field1 field 2"&options={"sort":{"field":-1}} -``` - -#### Conditions - -See [Mongoose find](http://mongoosejs.com/docs/api.html#model_Model.find) for details. - -#### Fields - -See [Mongoose field selection](http://mongoosejs.com/docs/api.html#query_Query-select) for details. - -#### Options - -See [Mongoose options](http://mongoosejs.com/docs/api.html#query_Query-setOptions) for details. - -## Finding a Specific [Document](http://docs.mongodb.org/manual/core/document/) by Id - -``` -GET /path/:id -``` - -### Advanced Querying - -See [Advanced Querying, above](#Advanced-Querying). - - -## Updating a Specific [Document](http://docs.mongodb.org/manual/core/document/) by Id - -``` -PUT /path/:id -``` - -## Deleting a Specific [Document](http://docs.mongodb.org/manual/core/document/) by Id - -``` -DELETE /path/:id -``` diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md deleted file mode 100644 index e72f2b3e..00000000 --- a/docs/Getting-Started.md +++ /dev/null @@ -1,116 +0,0 @@ -# Getting Started - -## Usage - -> **Important**: Read over the [Feathers documentation](http://feathersjs.com/#documentation), specifically the section on [Services](http://feathersjs.com/#toc12). All of the Feathers REST and Socket API requests are supported. - -### Run example: - -Run the following: - -``` -node example/index.js -``` - -Open your browser to [http://localhost:8080/user](http://localhost:8080/user). -There will likely be an empty array in response. This is because you have no `User`s, yet. - -I recommend the [Postman extension for Chrome](https://chrome.google.com/webstore/detail/postman-rest-client/fdmmgilgnpjigdojojpjoooidkmcomcm) if you would like an easy to use REST Client. - -### How To Create a Mongoose Service - -```javascript -var customService = new mongooseService(modelName, schema, mongoose); -``` - -See [Mongoose Schema Guide](http://mongoosejs.com/docs/guide.html) for more information on defining your schema. - -> Skip to [Step 3](#3-create-your-custom-mongoose-service) for the important part. - -#### 1) Get your dependencies. - -```javascript -// Get Feathers -var feathers = require('feathers'); -// Get Mongoose -var mongoose = require('mongoose'); -// Get Mongoose-Service -var mongooseService = require('feathers-mongoose-service'); -``` - -#### 2) Create your Mongoose connection to Mongodb. - -```javascript -// Get Mongoose -var mongoose = require('mongoose'); -// Connect to your MongoDB -mongoose.connect('mongodb://localhost/test'); -``` - -#### 3) **Create your custom Mongoose Service** - -```javascript -// Create your Mongoose-Service, for a `User` -var userService = mongooseService('user', { - email: {type : String, required : true, index: {unique: true, dropDups: true}}, - firstName: {type : String, required : true}, - lastName: {type : String, required : true}, - age: {type : Number, required : true}, - password: {type : String, required : true, select: false}, - skills: {type : Array, required : true} - }, mongoose); -``` - -#### 4) Use your service with Feathers. - -```javascript -// Setup Feathers -var app = feathers(); -// Configure Feathers -app.use(feathers.logger('dev')); // For debugging purposes. -// ................ -var port = 8080; -// ................ -app.configure(feathers.socketio()) - .use('/user', userService) // <-- Register your custom Mongoose-Service with Feathers - .listen(port, function() { - console.log('Express server listening on port ' + port); - }); -``` - -### Complete Working Example code: - -```javascript -// Get Feathers -var feathers = require('feathers'); -// Get Mongoose -var mongoose = require('mongoose'); -// Get Mongoose-Service -var mongooseService = require('feathers-mongoose-service'); -// Connect to your MongoDB -mongoose.connect('mongodb://localhost/test'); - -// Create your Mongoose-Service, for a `User` -var userService = mongooseService('user', { - email: {type : String, required : true, index: {unique: true, dropDups: true}}, - firstName: {type : String, required : true}, - lastName: {type : String, required : true}, - age: {type : Number, required : true}, - password: {type : String, required : true, select: false}, - skills: {type : Array, required : true} - }, mongoose); - -// Setup Feathers -var app = feathers(); - -// Configure Feathers -app.use(feathers.logger('dev')); // For debugging purposes. -// ................ -var port = 8080; -// ................ -app.configure(feathers.socketio()) - .use('/user', userService) // <-- Register your custom Mongoose-Service with Feathers - .listen(port, function() { - console.log('Express server listening on port ' + port); - }); -``` diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 693703dd..00000000 --- a/docs/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Documentation - -See [Getting-Started.md](Getting-Started.md) for instructions on using `Mongoose-Service` in your Node.js server. - -See [API.md](API.md) for details about the API and the many forms of queries that you can utilize. diff --git a/example/index.js b/example/index.js deleted file mode 100644 index 4c3b5a40..00000000 --- a/example/index.js +++ /dev/null @@ -1,33 +0,0 @@ -// Get Feathers -var feathers = require('feathers'); -// Get Mongoose -var mongoose = require('mongoose'); -// Connect to your MongoDB -mongoose.connect('mongodb://localhost/test'); - -// Get Mongoose-Service -var mongooseService = require('../lib'); // Mongoose-Service - -// Create your Mongoose-Service, for a `User` -var userService = mongooseService('user', { - email: {type : String, required : true, index: {unique: true, dropDups: true}}, - firstName: {type : String, required : true}, - lastName: {type : String, required : true}, - age: {type : Number, required : true}, - password: {type : String, required : true, select: false}, - skills: {type : Array, required : true} - }, mongoose); - -// Setup Feathers -var app = feathers(); - -// Configure Feathers -app.use(feathers.logger('dev')); // For debugging purposes. -// ................ -var port = 8080; -// ................ -app.configure(feathers.socketio()) - .use('/user', userService) // <-- Register your custom Mongoose-Service with Feathers - .listen(port, function() { - console.log('Express server listening on port ' + port); - }); diff --git a/examples/basic.js b/examples/basic.js new file mode 100644 index 00000000..1fd8a0f4 --- /dev/null +++ b/examples/basic.js @@ -0,0 +1,28 @@ +var feathers = require('feathers'), + bodyParser = require('body-parser'), + mongooseService = require('../lib/feathers-mongoose'), + Todo = require('./models/todo'); + +// Create a feathers instance. +var app = feathers() + // Setup the public folder. + .use(feathers.static(__dirname + '/public')) + // Enable Socket.io + .configure(feathers.socketio()) + // Enable REST services + .configure(feathers.rest()) + // Turn on JSON parser for REST services + .use(bodyParser.json()) + // Turn on URL-encoded parser for REST services + .use(bodyParser.urlencoded({extended: true})) + +// Connect to the db, create and register a Feathers service. +app.use('todos', mongooseService('todo', Todo)); + +app.configure(feathers.errors()); + +// Start the server. +var port = 8080; +app.listen(port, function() { + console.log('Feathers server listening on port ' + port); +}); \ No newline at end of file diff --git a/examples/models/todo.js b/examples/models/todo.js new file mode 100644 index 00000000..600944b4 --- /dev/null +++ b/examples/models/todo.js @@ -0,0 +1,22 @@ +var mongoose = require('mongoose'); + +var Todo = { + schema: { + title: {type: String, required: true}, + author: {type: String, required: true}, + description: {type: String}, + dueDate: {type: Date, 'default': Date.now}, + complete: {type: Boolean, 'default': false, index: true} + }, + methods: { + }, + statics: { + }, + virtuals: { + }, + indexes: [ + {'dueDate': -1, background: true} + ] +}; + +module.exports = Todo; \ No newline at end of file diff --git a/lib/feathers-mongoose.js b/lib/feathers-mongoose.js new file mode 100644 index 00000000..297d9bc7 --- /dev/null +++ b/lib/feathers-mongoose.js @@ -0,0 +1,267 @@ +var _ = require('lodash'); +var Proto = require('uberproto'); +var mongoose = require('mongoose'); +var errors = require('feathers-errors').types; +var filter = require('feathers-query-filters'); +var Schema = mongoose.Schema; +var ObjectId = mongoose.Types.ObjectId; + +var MongooseService = Proto.extend({ + + init: function(modelName, entity, options) { + options = options || {}; + + if (!_.isString(modelName)) { + throw new errors.GeneralError('Must provide a valid model name'); + } + + this.options = _.extend({}, options); + this.type = 'mongoose'; + + // It is a mongoose model already + if (entity.modelName) { + this.name = entity.modelName; + } + // It's not a model so we need to create one + else { + this.name = modelName; + + if (entity instanceof Schema) { + this.schema = entity; + } + else { + this.schema = this._createSchema(entity); + } + + mongoose.model(this.name, this.schema); + } + + this._connect(this.options); + this.model = this.store.model(this.name); + }, + + _createSchema: function(entity){ + var schema = new Schema(entity.schema, this.options); + + if (entity.methods) { + _.each(entity.methods, function(val, key){ + schema.methods[key] = val; + }); + } + + if (entity.statics) { + _.each(entity.statics, function(val, key){ + schema.statics[key] = val; + }); + } + + if (entity.virtuals) { + _.each(entity.virtuals, function(val, key){ + _.each(val, function(fn, method){ + schema.virtual(key)[method](fn); + }); + }); + } + + if (entity.indexes) { + _.each(entity.indexes, function(val){ + schema.index(val); + }); + } + + return schema; + }, + + // NOTE (EK): We create a new database connection for every MongooseService. + // This may not be good but... in the mean time the rationale for this + // design is because each user of a MongooseService instance could be a separate + // app residing on a totally different server, or each service could talk to + // totally different databases. + + // TODO (EK): We need to handle replica sets. + _connect: function(options) { + var connectionString = options.connectionString; + + if (options.connection) { + this.store = options.connection; + return; + } + + if (!connectionString) { + var config = _.extend({ + host: 'localhost', + port: 27017, + db: 'feathers', + reconnect: true + }, options); + + connectionString = 'mongodb://'; + + if (config.username && config.password) { + connectionString += config.username + ':' + config.password + '@'; + } + + connectionString += config.host + ':' + config.port + '/' + config.db; + + if (config.reconnect) { + connectionString += '?auto_reconnect=true'; + } + } + + // TODO (EK): Support mongoose connection options + // http://mongoosejs.com/docs/connections.html + this.store = mongoose.createConnection(connectionString); + }, + + find: function(params, cb) { + if (_.isFunction(params)) { + cb = params; + params = {}; + } + + params.query = params.query || {}; + var filters = filter(params.query); + + var query = this.model.find(params.query); + + if (filters.$select && filters.$select.length) { + var fields = {}; + + _.each(filters.$select, function(key){ + fields[key] = 1; + }); + + query.select(fields); + } + + if (filters.$sort){ + query.sort(filters.$sort); + } + + if (filters.$limit){ + query.limit(filters.$limit); + } + + if (filters.$skip){ + query.skip(filters.$skip); + } + + if (filters.$populate){ + query.populate(filters.$populate); + } + + query.exec(function(error, data) { + if (error) { + return cb(new errors.BadRequest(error)); + } + + cb(null, data); + }); + }, + + get: function(id, params, cb) { + if(_.isFunction(id)) { + cb = id; + return cb(new errors.BadRequest('A string or number id must be provided')); + } + + if(_.isFunction(params)) { + cb = params; + params = {}; + } + + var populate = params.query && params.query.populate ? params.query.populate : ''; + + // TODO (EK): handle params.fields & params.options + this.model + .findById(new ObjectId(id.toString())) + .populate(populate) + .exec(function (error, data) { + if (error) { + return cb(new errors.BadRequest(error)); + } + + if (!data) { + return cb(new errors.NotFound('No record found for id ' + id)); + } + + cb(null, data); + }); + }, + + // TODO (EK): Batch support for create, update, delete. + create: function(data, params, cb) { + if(_.isFunction(params)) { + cb = params; + params = {}; + } + + // Create our actual Model object + this.model.create(data, function (error, data) { + if (error) { + return cb(new errors.BadRequest(error)); + } + + var args = Array.prototype.slice.call(arguments); + data = args.slice(1, args.length); + + cb(null, data.length === 1 ? data[0] : data); + }); + }, + + patch: function(id, data, params, cb) { + this.update.call(this, id, data, params, cb); + }, + + update: function(id, data, params, cb) { + if (_.isFunction(data)) { + cb = data; + return cb(new errors.BadRequest('You need to provide data to be updated')); + } + + if (_.isFunction(params)) { + cb = params; + params = {}; + } + + // TODO (EK): Support updating multiple docs. Maybe we just use feathers-batch + this.model.findByIdAndUpdate(new ObjectId(id), data, {new: true}, function(error, data) { + if (error) { + return cb(new errors.BadRequest(error)); + } + + if (!data) { + return cb(new errors.NotFound('No record found for id ' + id)); + } + + cb(null, data); + }); + }, + + remove: function(id, params, cb) { + if(_.isFunction(params)) { + cb = params; + params = {}; + } + + this.model.findByIdAndRemove(new ObjectId(id), function(error, data) { + if (error) { + return cb(error); + } + + if (!data) { + return cb(new errors.NotFound('No record found for id ' + id)); + } + + cb(null, data); + }); + } +}); + +module.exports = function(modelName, schema, options) { + return Proto.create.call(MongooseService, modelName, schema, options); +}; + +module.exports.Service = MongooseService; + +module.exports.mongoose = mongoose; \ No newline at end of file diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index fd2cb295..00000000 --- a/lib/index.js +++ /dev/null @@ -1,104 +0,0 @@ -// Dependencies - -// ----------------- -module.exports = function(modelName, schema, mongoose) { - - // Constructor - function Store(modelName, schema, mongoose) { - // Extract - var Schema = mongoose.Schema; - var connection = mongoose.connection; - - // Set model name - this.name = modelName; - - // Create Schema - this.schema = new Schema(schema); - - // Create Model - this.model = connection.model(this.name, this.schema); - - return this; - } - - // Getter methods for 'model' and 'schema' - Store.prototype.getModel = function() { - return this.model; - }; - - Store.prototype.getSchema = function () { - return this.schema; - }; - - // Helpers - var parseQuery = function(query) { - if (typeof query !== 'object') - { - query = {}; - } - // Parse query arguments - for (var k in query) - { - if (typeof query[k] !== 'object') - { - try - { - query[k] = JSON.parse(query[k]); - } catch (e) - { - console.log('Error:', e); - query[k] = null; - } - } - } - return query; - }; - - // Returns a Model by it's id - Store.prototype.find = function (params, callback) { - if (typeof params === 'function') { - callback = params; - params = {}; - } - var query = parseQuery(params.query); - - this.model.find(query.conditions, query.fields, query.options, function (err, data) { - callback(err, data); - }); - }; - - Store.prototype.get = function (id, params, callback) { - if (typeof params === 'function') { - callback = params; - params = {}; - } - var query = parseQuery(params.query); - - this.model.findById(id, query.fields, query.options, function (err, data) { - callback(err, data); - }); - }; - - Store.prototype.create = function (data, params, callback) { - // Create our actual Model object so that we only get what we really want - var obj = new this.model(data); - obj.save(function (err, data) { - callback(err, data); - }); - }; - - Store.prototype.update = function (id, data, params, callback) { - this.model.findByIdAndUpdate(id, data, { upsert: true }, function(err, data) { - return callback(err, data); - }); - }; - - Store.prototype.remove = function (id, params, callback) { - this.model.findByIdAndRemove(id, function(err, data) { - return callback(err, data); - }); - }; - - return new Store(modelName, schema, mongoose); - -}; diff --git a/package.json b/package.json index 74c009c3..53ccc4ca 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "feathers-mongoose", - "version": "1.2.0", - "description": "Easily create a Mongoose Service for Featherjs.", - "main": "lib/index.js", - "scripts": { - "test": "grunt test" - }, + "description": "Feathers Mongoose ORM service", + "version": "2.0.0", + "homepage": "https://github.com/feathersjs/feathers-mongoose", "repository": { "type": "git", - "url": "git://github.com/feathersjs/feathers-mongoose-service.git" + "url": "git://github.com/feathersjs/feathers-mongoose.git" + }, + "bugs": { + "url": "https://github.com/feathersjs/feathers-mongoose/issues" }, + "license": "MIT", "keywords": [ "feathers", "feathers-plugin", @@ -21,27 +22,37 @@ "mongoose", "service" ], - "author": "Glavin Wiechert (https://github.com/Glavin001)", - "license": "MIT", - "bugs": { - "url": "https://github.com/feathersjs/feathers-mongoose-service/issues" + "author": "Feathers (http://feathersjs.com)", + "contributors": [ + "Eric Kryski (http://erickryski.com)", + "Glavin Wiechert (https://github.com/Glavin001)" + ], + "main": "lib/feathers-mongoose.js", + "scripts": { + "publish": "git push origin --tags", + "release:patch": "npm version patch && npm publish", + "release:minor": "npm version minor && npm publish", + "release:major": "npm version major && npm publish", + "jshint": "jshint lib/. test/. --config", + "mocha": "mocha test/ --recursive --timeout 5000", + "test": "npm run jshint && npm run mocha" }, - "homepage": "https://github.com/feathersjs/feathers-mongoose-service", - "devDependencies": { - "mocha": "*", - "grunt-cli": "~0.1.7", - "grunt": "~0.4.1", - "grunt-release": "~0.7.0", - "grunt-contrib-jshint": "~0.x", - "grunt-simple-mocha": "~0.4.0", - "grunt-contrib-watch": "~0.5.3", - "grunt-express-server": "~0.4.11", - "feathers": ">= 0.3.0 < 1", - "mongoose": ">= 3.8.4 < 4" + "engines": { + "node": "~0.10.0", + "npm": "~1.4.0" + }, + "dependencies": { + "feathers-errors": "^0.2.5", + "feathers-query-filters": "^1.1.1", + "lodash": "^2.4.1", + "mongoose": "^4.1.0", + "uberproto": "^1.1.2" }, - "dependencies": {}, - "peerDependencies": { - "feathers": ">= 0.3.0 < 1" - , "mongoose": ">= 3.8.4 < 4" + "devDependencies": { + "body-parser": "^1.13.2", + "chai": "^3.0.0", + "feathers": "^1.1.0", + "jshint": "^2.8.0", + "mocha": "^2.2.5" } } diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 00000000..6265f342 --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,27 @@ +module.exports = { + singlePost: { + title: 'First Post', + author: 'Alice' + }, + multiplePosts: [ + { + title: 'Second Post', + author: 'Bob' + }, + { + title: 'Third and Final Post', + author: 'Doug' + } + ], + invalidPost: [ + { + title: 'Invalid Post' + } + ], + comments: [ + { + content: 'First comment', + commenter: 'Anonymous' + } + ] +}; \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 8c62c9b7..00000000 --- a/test/index.test.js +++ /dev/null @@ -1,47 +0,0 @@ -var assert = require('assert'); -var mongooseService = require('../lib'); -var mongoose = require('mongoose'); - -var connection = mongoose.connect('mongodb://localhost/test'); - -describe('Usage', function() { - - /* - // Old usage - describe('#new(connection)', function() { - it('should return a new Mongoose Service', function() { - - var service = new mongooseService('test', { - field: {type: String} - }, connection); - - if (!!service) { - // passed - } - assert.equal(-1, [1,2,3].indexOf(5)); - assert.equal(-1, [1,2,3].indexOf(0)); - - }); - }); - */ - - describe('Mongoose - Check Connection', function() { - it('should have a valid connection', function() { - assert.equal(true, !!connection); - }); - }); - - describe('#new(mongoose)', function() { - it('should return a new Mongoose Service', function() { - var service = new mongooseService('test', { - field: {type: String} - }, mongoose); - assert.equal(true, !!service); - assert.equal(true, !!service.find); - assert.equal(true, !!service.get); - assert.equal(true, !!service.create); - assert.equal(true, !!service.update); - assert.equal(true, !!service.remove); - }); - }); -}); \ No newline at end of file diff --git a/test/test.js b/test/test.js new file mode 100644 index 00000000..776b48d7 --- /dev/null +++ b/test/test.js @@ -0,0 +1,516 @@ +var chai = require('chai'); +var expect = chai.expect; +var Fixtures = require('./fixtures'); +var errors = require('feathers-errors').types; +var mongooseService = require('../lib/feathers-mongoose'); +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var _ids = {}; +var Comment = { + schema: { + content: {type: String, required: true}, + commenter: {type: String, required: true} + } +}; + +var Post = { + schema: { + title: {type: String, required: true}, + author: {type: String, required: true}, + published: {type: Boolean, 'default': false}, + comments: [{type: Schema.ObjectId, ref: 'comment'}] + }, + methods: { + instanceMethod: function(){} + }, + statics: { + staticMethod: function(){} + }, + virtuals: { + 'virtualAttribute': { + get: function(){ return 'virtual'; } + } + }, + indexes: [ + {'published': 1, background: true} + ] +}; + +describe('Feathers Mongoose Service', function() { + + describe('#init', function() { + beforeEach(function(){ + mongoose.models = {}; + mongoose.modelSchemas = {}; + mongoose.connection.models = {}; + mongoose.connection.collections = {}; + }); + + describe('without model name', function() { + it('throws an error', function() { + expect(mongooseService).to.throw('Must provide a valid model name'); + }); + }); + + describe('with model name', function() { + it('sets the type', function(){ + var service = mongooseService('post', Post, {db: 'test'}); + expect(service.type).to.equal('mongoose'); + }); + + it('sets custom options', function(){ + var service = mongooseService('post', Post, {db: 'test'}); + expect(service.options.db).equal('test'); + }); + + it('changes a default mongoose option', function(){ + var service = mongooseService('post', Post, {versionKey: '__version'}); + expect(service.options.versionKey).equal('__version'); + }); + + describe('when entity is mongoose model', function() { + it('sets the name', function(){ + var PostSchema = mongoose.Schema(Post.schema); + var PostModel = mongoose.model('post', PostSchema); + var service = mongooseService('post', PostModel, {db: 'test'}); + expect(service.name).to.equal('post'); + }); + }); + + describe('when entity is a mongoose schema', function() { + it('sets the name', function(){ + var PostSchema = mongoose.Schema(Post.schema); + var service = mongooseService('post', PostSchema, {db: 'test'}); + expect(service.name).to.equal('post'); + }); + }); + + describe('when entity is an object literal', function() { + it('sets the name', function(){ + var service = mongooseService('post', Post, {db: 'test'}); + expect(service.name).to.equal('post'); + }); + + it('sets any instance methods', function() { + var service = mongooseService('post', Post, {db: 'test'}); + var post = new service.model({title: 'A post', author: 'Ernest'}); + expect(post.instanceMethod).to.not.be.undefined; + }); + + it('sets any static methods', function() { + var service = mongooseService('post', Post, {db: 'test'}); + expect(service.model.staticMethod).to.not.be.undefined; + }); + + it('sets any virtual methods', function() { + var service = mongooseService('post', Post, {db: 'test'}); + var post = new service.model({title: 'A post', author: 'Ernest'}); + expect(post.virtualAttribute).to.equal('virtual'); + }); + + it('sets any indexes', function() { + mongooseService('post', Post, {db: 'test'}); + var indexes = mongoose.modelSchemas.post._indexes; + expect(indexes.length).to.not.equal(0); + }); + }); + }); + + describe('with existing connection', function() { + it('uses the existing connection', function(done) { + var connection = mongoose.createConnection('mongodb://localhost:27017/test'); + var service = mongooseService('post', Post, {connection: connection}); + + service.find({}, function(error, data){ + expect(error).to.be.null; + expect(data).to.be.ok; + done(); + }); + }); + }); + + describe('with connection string', function() { + it('sets up a connection', function(done) { + var service = mongooseService('post', Post, {db: 'test'}); + + service.find({}, function(error, data){ + expect(error).to.be.null; + expect(data).to.be.ok; + done(); + }); + }); + }); + + describe('without connection string', function() { + it('sets up a default connection', function(done) { + var service = mongooseService('post', Post); + + service.find({}, function(error, data){ + expect(error).to.be.null; + expect(data).to.be.ok; + done(); + }); + }); + + it('sets up a custom connection', function(done) { + var options = { + host: 'localhost', + port: 27017, + db: 'test', + reconnect: false + }; + + var service = mongooseService('post', Post, options); + + service.find({}, function(error, data){ + expect(error).to.be.null; + expect(data).to.be.ok; + done(); + }); + }); + }); + }); + + describe('CRUD methods', function(){ + before(function(){ + mongoose.models = {}; + mongoose.modelSchemas = {}; + mongoose.connection.models = {}; + mongoose.connection.collections = {}; + this.service = mongooseService('post', Post); + this.commentService = mongooseService('comment', Comment); + }); + + beforeEach(function(done) { + this.service.create(Fixtures.singlePost, function(error, data) { + if (error) { + console.error(error); + } + + _ids.FirstPost = data._id; + done(); + }); + }); + + afterEach(function(done) { + this.service.remove(_ids.FirstPost, function() { + done(); + }); + }); + + describe('#find', function() { + beforeEach(function(done) { + var secondPost = Fixtures.multiplePosts[0]; + var thirdPost = Fixtures.multiplePosts[1]; + + this.commentService.create(Fixtures.comments[0], function(err, comment){ + secondPost.comments = [comment._id]; + + this.service.create(secondPost, function(err, post) { + _ids.SecondPost = post._id; + + this.service.create(thirdPost, function(err, post) { + _ids.ThirdPost = post._id; + done(); + }); + }.bind(this)); + }.bind(this)); + }); + + afterEach(function(done) { + this.service.remove(_ids.SecondPost, function() { + this.service.remove(_ids.ThirdPost, function() { + done(); + }); + }.bind(this)); + }); + + it('returns all items', function(done) { + this.service.find({}, function(error, data) { + expect(error).to.be.null; + expect(data).to.be.instanceof(Array); + expect(data.length).to.equal(3); + done(); + }); + }); + + it('filters results by query parameters', function(done) { + this.service.find({ query: { title: 'First Post' } }, function(error, data) { + expect(error).to.be.null; + expect(data).to.be.instanceof(Array); + expect(data.length).to.equal(1); + expect(data[0].title).to.equal('First Post'); + done(); + }); + }); + + it('supports sorting', function(done) { + var params = { + query: { + $sort: {author: 1} + } + }; + + this.service.find(params, function(error, data) { + expect(error).to.be.null; + expect(data.length).to.equal(3); + expect(data[0].author).to.equal('Alice'); + expect(data[1].author).to.equal('Bob'); + expect(data[2].author).to.equal('Doug'); + done(); + }); + }); + + it('supports limiting', function(done) { + var params = { + query: { + $limit: 2 + } + }; + + this.service.find(params, function(error, data) { + expect(error).to.be.null; + expect(data.length).to.equal(2); + done(); + }); + }); + + it('supports skipping', function(done) { + var params = { + query: { + $sort: {author: 1}, + $skip: 1 + } + }; + + this.service.find(params, function(error, data) { + expect(error).to.be.null; + expect(data.length).to.equal(2); + expect(data[0].author).to.equal('Bob'); + expect(data[1].author).to.equal('Doug'); + done(); + }); + }); + + it('supports selecting specific fields', function(done) { + var params = { + query: { + author: 'Alice', + $select: ['title'] + } + }; + + this.service.find(params, function(error, data) { + expect(error).to.be.null; + expect(data.length).to.equal(1); + expect(data[0].title).to.equal('First Post'); + expect(data[0].author).to.be.undefined; + done(); + }); + }); + + it('supports populating sub documents', function(done) { + var params = { + query: { + author: 'Bob', + $populate: ['comments'] + } + }; + + this.service.find(params, function(error, data) { + expect(error).to.be.null; + expect(data.length).to.equal(1); + expect(data[0].title).to.equal('Second Post'); + expect(data[0].comments[0].content).to.equal('First comment'); + done(); + }); + }); + }); + + describe('#get', function() { + it('returns a BadRequest error when no id is provided', function(done) { + this.service.get(function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.BadRequest).to.be.ok; + done(); + }); + }); + + describe('when instance exists', function(){ + it('returns the found instance', function(done) { + this.service.get(_ids.FirstPost, function(error, data) { + expect(data._id.toString()).to.equal(_ids.FirstPost.toString()); + done(); + }); + }); + + it('does not return an error', function(done) { + this.service.get(_ids.FirstPost, function(error) { + expect(error).to.be.null; + done(); + }); + }); + }); + + describe('when instance does not exist', function(){ + it('returns NotFound error for non-existing id', function(done) { + this.service.get('55c0f8c7c0846306132077ab', function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.NotFound).to.be.ok; + expect(error.message).to.equal('No record found for id 55c0f8c7c0846306132077ab'); + done(); + }); + }); + }); + }); + + describe('#create', function() { + describe('when invalid', function(){ + it('returns a BadRequest error', function(done) { + this.service.create(Fixtures.invalidPost, function(error) { + expect(error instanceof errors.BadRequest).to.be.ok; + done(); + }); + }); + + it('returns mongoose validation error', function(done) { + this.service.create(Fixtures.invalidPost, function(error) { + expect(error.message).to.equal('Validation failed'); + expect(error.errors.author.path).to.equal('author'); + expect(error.errors.author.message).to.equal('Path `author` is required.'); + done(); + }); + }); + }); + + describe('when valid', function(){ + it('supports creating single instances and returns it', function(done) { + this.service.create(Fixtures.singlePost, function(error, data) { + expect(data.title).to.equal('First Post'); + + this.service.remove(data._id, function(){ + done(); + }); + }.bind(this)); + }); + + it.skip('supports creating multiple instances and returns them', function(done) { + this.service.create(Fixtures.multiplePosts, function(error, data) { + expect(data[0].title).to.equal('Second Post'); + expect(data[1].title).to.equal('Third Post'); + done(); + }); + }); + }); + }); + + describe('#remove', function() { + describe('when instance exists', function(){ + it('deletes instance and returns the deleted instance', function(done) { + this.service.remove(_ids.FirstPost, function(error, data) { + expect(data.title).to.equal('First Post'); + done(); + }); + }); + + it('does not return an error', function(done) { + this.service.remove(_ids.FirstPost, function(error) { + expect(error).to.be.null; + done(); + }); + }); + }); + + describe('when instance does not exist', function(){ + it('returns NotFound error for non-existing id', function(done) { + this.service.remove('55c0f8c7c0846306132077ab', function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.NotFound).to.be.ok; + expect(error.message).to.equal('No record found for id 55c0f8c7c0846306132077ab'); + done(); + }); + }); + }); + }); + + describe('#update', function() { + describe('when instance exists', function(){ + it('updates instance and returns the updated instance', function(done) { + this.service.update(_ids.FirstPost, { title: 'New Title'}, function(error, data) { + expect(data.title).to.equal('New Title'); + done(); + }); + }); + + it('does not return an error', function(done) { + this.service.update(_ids.FirstPost, {}, function(error) { + expect(error).to.be.null; + done(); + }); + }); + }); + + describe('when instance does not exist', function(){ + it('returns NotFound error for non-existing id', function(done) { + this.service.update('55c0f8c7c0846306132077ab', {}, function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.NotFound).to.be.ok; + expect(error.message).to.equal('No record found for id 55c0f8c7c0846306132077ab'); + done(); + }); + }); + }); + + describe('invalid params', function(){ + it('returns BadRequest error when not passing data', function(done) { + this.service.update(_ids.FirstPost, function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.BadRequest).to.be.ok; + expect(error.message).to.equal('You need to provide data to be updated'); + done(); + }); + }); + }); + }); + + describe('#patch', function() { + describe('when instance exists', function(){ + it('patches instance and returns the patched instance', function(done) { + this.service.patch(_ids.FirstPost, { title: 'New Title'}, function(error, data) { + expect(data.title).to.equal('New Title'); + done(); + }); + }); + + it('does not return an error', function(done) { + this.service.patch(_ids.FirstPost, {}, function(error) { + expect(error).to.be.null; + done(); + }); + }); + }); + + describe('when instance does not exist', function(){ + it('returns NotFound error for non-existing id', function(done) { + this.service.patch('55c0f8c7c0846306132077ab', {}, function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.NotFound).to.be.ok; + expect(error.message).to.equal('No record found for id 55c0f8c7c0846306132077ab'); + done(); + }); + }); + }); + + describe('invalid params', function(){ + it('returns BadRequest error when not passing data', function(done) { + this.service.patch(_ids.FirstPost, function(error) { + expect(error).to.be.ok; + expect(error instanceof errors.BadRequest).to.be.ok; + expect(error.message).to.equal('You need to provide data to be updated'); + done(); + }); + }); + }); + }); + }); +});