├── .eslintignore ├── .gitignore ├── index.js ├── .npmignore ├── .travis.yml ├── lib ├── index.js └── cast.pipeline.js ├── CHANGELOG.md ├── package.json ├── README.md ├── test ├── plugin.test.js └── cast.pipeline.test.js └── .eslintrc /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .eslintignore 3 | .git 4 | .nyc_output 5 | .travis.yml 6 | node_modules 7 | test 8 | package.json 9 | README.md -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: [14, 13, 12, 11, 10] 3 | services: 4 | - "mongodb" 5 | script: "npm run-script test-travis" 6 | after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const castPipeline = require('./cast.pipeline'); 2 | 3 | function castAggregationPlugin (schema) { 4 | schema.pre('aggregate', function () { 5 | const pipeline = this.pipeline(); 6 | 7 | castPipeline(this._model, pipeline); 8 | }); 9 | } 10 | 11 | module.exports = castAggregationPlugin; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.3.1 / 2022-07-07 2 | =================== 3 | * docs: add `$search` and `$searchMeta` to README for NPM homepage. 4 | 5 | 0.3.0 / 2022-07-06 6 | =================== 7 | * fix: allow casting stages after `$search` and `$searchMeta` re #11 8 | 9 | 0.2.1 / 2021-11-15 10 | =================== 11 | * fix: use mongoose version >= 5.x instead of a specific version 12 | 13 | 0.2.0 / 2021-09-09 14 | =================== 15 | * fix: add support for mongoose v6.x 16 | 17 | 0.1.0 / 2020-05-30 18 | =================== 19 | * feat: cast query on `$geoNear` stages #3 20 | -------------------------------------------------------------------------------- /lib/cast.pipeline.js: -------------------------------------------------------------------------------- 1 | const { Query } = require('mongoose'); 2 | const mongooseQuery = new Query(); 3 | 4 | const stagesThatDoNotAffectProjection = Object.freeze(['$match', '$limit', '$sort', '$skip', '$sample', '$search', '$searchMeta']); 5 | 6 | function castPipeline (model, pipeline) { 7 | for (const stage of pipeline) { 8 | const stageName = getStageName(stage); 9 | 10 | if (stageName === '$geoNear' && stage.$geoNear.query) { 11 | castFilter(model, stage.$geoNear.query); 12 | } 13 | 14 | const projectionHasChanged = !stagesThatDoNotAffectProjection.includes(stageName); 15 | if (projectionHasChanged) { 16 | return; 17 | } 18 | 19 | if (stageName === '$match') stage[stageName] = castFilter(model, stage[stageName]); 20 | } 21 | } 22 | 23 | function getStageName (stage) { 24 | return Object.keys(stage)[0]; 25 | } 26 | 27 | function castFilter (Model, filter) { 28 | return mongooseQuery.cast(Model, filter); 29 | } 30 | 31 | module.exports = castPipeline; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-cast-aggregation", 3 | "version": "0.3.1", 4 | "description": "A mongoose plugin that casts $match at aggregation pipelines whenever possible", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --exit", 8 | "tdd": "nodemon --exec \"mocha\"", 9 | "test-travis": "nyc --reporter=lcov npm test && nyc check-coverage" 10 | }, 11 | "keywords": [ 12 | "mongoose", 13 | "cast", 14 | "aggregation", 15 | "query", 16 | "match", 17 | "filter" 18 | ], 19 | "author": "Abdelrahman Hafez ", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/AbdelrahmanHafez/mongoose-cast-aggregation/issues" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git://github.com/AbdelrahmanHafez/mongoose-cast-aggregation.git" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^8.19.0", 30 | "eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0", 31 | "mocha": "^10.0.0", 32 | "mongoose": ">=5.x", 33 | "nyc": "^15.1.0" 34 | }, 35 | "peerDependencies": { 36 | "mongoose": ">=5.x" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mongoose Cast Aggregation 2 | 3 | A mongoose plugin that casts aggregation pipelines whenever possible. 4 | 5 | [![Build Status](https://travis-ci.org/AbdelrahmanHafez/mongoose-cast-aggregation.svg?branch=master)](https://travis-ci.org/AbdelrahmanHafez/mongoose-cast-aggregation) 6 | [![Coverage Status](https://coveralls.io/repos/github/AbdelrahmanHafez/mongoose-cast-aggregation/badge.svg?branch=master)](https://coveralls.io/github/AbdelrahmanHafez/mongoose-cast-aggregation?branch=master) 7 | [![NPM version](https://badge.fury.io/js/mongoose-cast-aggregation.svg)](http://badge.fury.io/js/mongoose-cast-aggregation) 8 | 9 | [![NPM](https://nodei.co/npm/mongoose-cast-aggregation.png)](https://nodei.co/npm/mongoose-cast-aggregation/) 10 | 11 | ## Getting Started 12 | 13 | 14 | Run: 15 | ``` 16 | npm install mongoose-cast-aggregation 17 | ``` 18 | 19 | Inject the plugin into mongoose: 20 | ```js 21 | const mongoose = require('mongoose'); 22 | const castAggregation = require('mongoose-cast-aggregation'); 23 | 24 | mongoose.plugin(castAggregation); 25 | ``` 26 | 27 | 28 | Now mongoose will cast the `$match` stage whenever possible. It casts the `$match` stage as long as no stage before it changed the resulting document shape from the original schema (e.g. `$match`, `$limit`, `$sort`, `$skip`, `$sample`, `$search`, and `$searchMeta`). It also casts `query` on $geoNear stages. 29 | ```js 30 | // After injecting the plugin 31 | const discountSchema = new Schema({ 32 | expiresAt: Date, 33 | amount: Number 34 | }); 35 | 36 | const Discount = mongoose.model('Discount', discountSchema); 37 | 38 | const discounts = await Discount.aggregate([ 39 | // Will cast the amount to a number, and the timestamp to a date object 40 | { $match: { expiresAt: { $lt: Date.now() }, amount: '20' } } 41 | ]); 42 | ``` 43 | 44 | This works as well: 45 | 46 | ```js 47 | const discounts = await Discount.aggregate([ 48 | { $sort: { amount:-1 } }, 49 | { $skip: 20 }, 50 | // Will cast the stage below to a date object, because the document shape hasn't changed yet. 51 | { $match: { expiresAt: { $lt: Date.now() } } }, 52 | 53 | // Will cast this one to numbers as well. 54 | { $match: { amount: { $gt: '80', $lt: '200' } } }, 55 | { $project: { amountInUSD: '$amount' } }, 56 | 57 | // Will ***NOT*** cast this one, because we used a stage that changed the shape of the document. 58 | // so using the string '100' here will not work, will have to use the correct type of number in order to get results. 59 | { $match: { amountInUSD: { $gt: 100 } } } 60 | ]); 61 | ``` -------------------------------------------------------------------------------- /test/plugin.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { Schema } = mongoose; 3 | const aggregationCastPlugin = require('../index'); 4 | const assert = require('assert'); 5 | 6 | async function connect () { 7 | await mongoose.connect('mongodb://127.0.0.1/mongoose_aggregation_cast_test'); 8 | } 9 | 10 | const connectionPromise = connect(); 11 | 12 | const userSchema = new Schema({ 13 | expiresAt: Date, 14 | age: Number 15 | }); 16 | 17 | userSchema.plugin(aggregationCastPlugin); 18 | 19 | const User = mongoose.model('User', userSchema); 20 | 21 | describe('aggregationCastPlugin', function () { 22 | 23 | this.beforeAll(async function () { 24 | await connectionPromise; 25 | await mongoose.connection.dropDatabase(); 26 | }); 27 | 28 | it('is a function', () => { 29 | assert.equal(typeof aggregationCastPlugin, 'function'); 30 | }); 31 | 32 | it('casts first stage when it is $match', async function () { 33 | // Arrange 34 | const expiresAtTimestamp = Date.now(); 35 | const stringifiedAge = '25'; 36 | 37 | const user = await User.create({ age: stringifiedAge, expiresAt: expiresAtTimestamp }); 38 | 39 | // Act 40 | const usersFromAggregation = await User.aggregate([ 41 | { $match: { _id: user._id.toString(), age: stringifiedAge } } 42 | ]); 43 | 44 | // Assert 45 | assert.equal(usersFromAggregation[0]._id.toString(), user._id.toString()); 46 | }); 47 | 48 | describe('casts $match when it comes after a stage that does not change projection', () => { 49 | 50 | it('$sort', async function () { 51 | // Arrange 52 | const expiresAtTimestamp = Date.now(); 53 | const stringifiedAge = '25'; 54 | 55 | const user = await User.create({ age: stringifiedAge, expiresAt: expiresAtTimestamp }); 56 | 57 | // Act 58 | const usersFromAggregation = await User.aggregate([ 59 | { $sort: { age: -1 } }, 60 | { $match: { _id: user._id.toString(), age: stringifiedAge } } 61 | ]); 62 | 63 | // Assert 64 | assert.equal(usersFromAggregation[0]._id.toString(), user._id.toString()); 65 | }); 66 | 67 | it('$match', async function () { 68 | // Arrange 69 | const expiresAtTimestamp = Date.now(); 70 | const stringifiedAge = '25'; 71 | 72 | const user = await User.create({ age: stringifiedAge, expiresAt: expiresAtTimestamp }); 73 | 74 | // Act 75 | const usersFromAggregation = await User.aggregate([ 76 | { $match: { age: stringifiedAge } }, 77 | { $match: { _id: user._id.toString() } } 78 | ]); 79 | 80 | // Assert 81 | assert.equal(usersFromAggregation[0]._id.toString(), user._id.toString()); 82 | }); 83 | }); 84 | 85 | 86 | }); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 9 4 | }, 5 | "rules": { 6 | "camelcase": 0, 7 | "comma-dangle": [2, "never"], 8 | "comma-spacing": [2, { 9 | "before": false, 10 | "after": true 11 | }], 12 | "arrow-spacing": [2, { 13 | "before": true, 14 | "after": true 15 | }], 16 | "array-bracket-spacing": 1, 17 | "consistent-return": 0, 18 | "curly": 0, 19 | "default-case": 0, 20 | "eqeqeq": [2, "smart"], 21 | "func-names": 0, 22 | "guard-for-in": 2, 23 | "indent": [2, 2, { 24 | "SwitchCase": 1 25 | }], 26 | "key-spacing": [2, { 27 | "beforeColon": false, 28 | "afterColon": true 29 | }], 30 | "keyword-spacing": [2, { 31 | "before": true, 32 | "after": true 33 | }], 34 | "max-len": 0, 35 | "new-cap": [2, { 36 | "newIsCapExceptions": ["mongoose", "mongoose.Schema", "mongoose.Types.ObjectId"], 37 | "capIsNewExceptions": ["Schema", "mongoose.Schema", "mongoose.Types.ObjectId", "ObjectId"] 38 | }], 39 | "no-bitwise": 0, 40 | "no-caller": 2, 41 | "no-console": 0, 42 | "no-else-return": 0, 43 | "no-empty-class": 0, 44 | "no-multi-spaces": 2, 45 | "no-multiple-empty-lines": ["error", { 46 | "max": 5, 47 | "maxBOF": 0 48 | }], 49 | "no-param-reassign": 0, 50 | "no-shadow": 0, 51 | "no-spaced-func": 2, 52 | "func-style": ["error", "declaration"], 53 | "no-throw-literal": 2, 54 | "no-trailing-spaces": "error", 55 | "no-undef": "error", 56 | "no-unneeded-ternary": 2, 57 | "no-unreachable": 2, 58 | "no-underscore-dangle": 0, 59 | "no-unused-expressions": 0, 60 | "no-unused-vars": ["error", { 61 | "args": "none" 62 | }], 63 | "id-length": ["error", { 64 | "min": 3, 65 | "exceptions": ["i", "a", "b", "_", "fs", "$", "db", "to", "io", "id"], 66 | "properties": "never" 67 | }], 68 | "no-const-assign": "error", 69 | "object-shorthand": "error", 70 | "no-useless-rename": "error", 71 | "require-await": "error", 72 | "prefer-const": ["error", { 73 | "destructuring": "all" 74 | 75 | }], 76 | "prefer-destructuring": ["error", { 77 | "VariableDeclarator": { 78 | "array": true, 79 | "object": true 80 | }, 81 | "AssignmentExpression": { 82 | "array": true, 83 | "object": false 84 | } 85 | }], 86 | "eol-last": ["error", "never"], 87 | "no-use-before-define": [1, "nofunc"], 88 | "no-var": ["error"], 89 | "no-dupe-keys": "error", 90 | "object-curly-spacing": [2, "always"], 91 | "one-var": ["error", "never"], 92 | "one-var-declaration-per-line": [2, "always"], 93 | "padded-blocks": 0, 94 | "space-before-blocks": [2, "always"], 95 | "space-before-function-paren": [2, "always"], 96 | "space-infix-ops": 2, 97 | "space-in-parens": [2, "never"], 98 | "spaced-comment": [2, "always"], 99 | "template-curly-spacing": ["error", "never"], 100 | "no-whitespace-before-property": "error", 101 | "strict": 0, 102 | "quote-props": ["error", "as-needed"], 103 | "quotes": ["error", "single"], 104 | "semi": "error", 105 | "no-extra-semi": "error", 106 | "semi-spacing": "error", 107 | "wrap-iife": [2, "outside"], 108 | "prefer-template": "error" 109 | }, 110 | "env": { 111 | "node": true, 112 | "mocha": true, 113 | "es6": true 114 | }, 115 | "extends": ["plugin:you-dont-need-lodash-underscore/compatible"] 116 | } -------------------------------------------------------------------------------- /test/cast.pipeline.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { ObjectId } = mongoose.Types; 3 | const { Schema } = mongoose; 4 | 5 | 6 | const assert = require('assert'); 7 | const castPipeline = require('../lib/cast.pipeline'); 8 | 9 | beforeEach(function () { 10 | mongoose.deleteModel(/.*/); 11 | }); 12 | 13 | describe('castPipeline(...)', function () { 14 | it('casts first stage when it is $match', function () { 15 | // Arrange 16 | const User = mongoose.model('User', { age: Number }); 17 | 18 | const stringifiedAge = '25'; 19 | 20 | const pipeline = [ 21 | { $match: { _id: ObjectId().toString(), age: stringifiedAge } } 22 | ]; 23 | 24 | // Act 25 | castPipeline(User, pipeline); 26 | 27 | // Assert 28 | assert.ok(pipeline[0].$match._id instanceof ObjectId); 29 | assert.equal(typeof pipeline[0].$match.age, 'number'); 30 | }); 31 | 32 | describe('casts $match when it comes after a stage that does not change projection', () => { 33 | 34 | it('$sort', function () { 35 | // Arrange 36 | const User = mongoose.model('User', { age: Number }); 37 | 38 | const stringifiedAge = '25'; 39 | const pipeline = [ 40 | { $sort: { age: -1 } }, 41 | { $match: { _id: ObjectId().toString(), age: stringifiedAge } } 42 | ]; 43 | 44 | // Act 45 | castPipeline(User, pipeline); 46 | 47 | // Assert 48 | assert.ok(pipeline[1].$match._id instanceof ObjectId); 49 | assert.equal(typeof pipeline[1].$match.age, 'number'); 50 | }); 51 | 52 | it('$match', function () { 53 | // Arrange 54 | const discountSchema = new Schema({ 55 | expiresAt: Date, 56 | age: Number 57 | }); 58 | 59 | const Discount = mongoose.model('Discount', discountSchema); 60 | const expiresAtTimestamp = Date.now(); 61 | const stringifiedAge = '25'; 62 | 63 | const pipeline = [ 64 | { $match: { age: stringifiedAge } }, 65 | { $match: { expiresAt: expiresAtTimestamp } } 66 | ]; 67 | 68 | // Act 69 | castPipeline(Discount, pipeline); 70 | 71 | // Assert 72 | assert.equal(typeof pipeline[0].$match.age, 'number'); 73 | assert.ok(pipeline[1].$match.expiresAt instanceof Date); 74 | }); 75 | it('$search', function () { 76 | // Arrange 77 | const discountSchema = new Schema({ 78 | expiresAt: Date, 79 | age: Number 80 | }); 81 | 82 | const Discount = mongoose.model('Discount', discountSchema); 83 | const expiresAtTimestamp = Date.now(); 84 | const stringifiedAge = '25'; 85 | 86 | const pipeline = [ 87 | { $search: { age: stringifiedAge } }, 88 | { $match: { age: stringifiedAge } }, 89 | { $match: { expiresAt: expiresAtTimestamp } } 90 | ]; 91 | 92 | // Act 93 | castPipeline(Discount, pipeline); 94 | 95 | // Assert 96 | assert.equal(typeof pipeline[1].$match.age, 'number'); 97 | assert.ok(pipeline[2].$match.expiresAt instanceof Date); 98 | }); 99 | it('$searchMeta', () => { 100 | // Arrange 101 | const discountSchema = new Schema({ 102 | expiresAt: Date, 103 | age: Number 104 | }); 105 | 106 | const Discount = mongoose.model('Discount', discountSchema); 107 | const expiresAtTimestamp = Date.now(); 108 | const stringifiedAge = '25'; 109 | 110 | const pipeline = [ 111 | { $searchMeta: { age: stringifiedAge } }, 112 | { $match: { age: stringifiedAge } }, 113 | { $match: { expiresAt: expiresAtTimestamp } } 114 | ]; 115 | 116 | // Act 117 | castPipeline(Discount, pipeline); 118 | 119 | // Assert 120 | assert.equal(typeof pipeline[1].$match.age, 'number'); 121 | assert.ok(pipeline[2].$match.expiresAt instanceof Date); 122 | }); 123 | }); 124 | 125 | describe('$geoNear', function () { 126 | it('casts query on $geoNear', function () { 127 | // Arrange 128 | const User = mongoose.model('User', { age: Number }); 129 | 130 | const pipeline = [ 131 | { $geoNear: { query: { age: '25' } } } 132 | ]; 133 | 134 | // Act 135 | castPipeline(User, pipeline); 136 | 137 | // Assert 138 | assert.equal(typeof pipeline[0].$geoNear.query.age, 'number'); 139 | }); 140 | 141 | it('stops casting after $geoNear', function () { 142 | // Arrange 143 | const Discount = mongoose.model('Discount', { amount: Number, expiresAt: Date }); 144 | 145 | const pipeline = [ 146 | { $geoNear: { query: { amount: '25' } } }, 147 | { $match: { expiresAt: Date.now() } } 148 | ]; 149 | 150 | // Act 151 | castPipeline(Discount, pipeline); 152 | 153 | // Assert 154 | assert.ok(typeof pipeline[0].$geoNear.query.amount === 'number'); 155 | assert.ok(typeof pipeline[1].$match.expiresAt === 'number'); 156 | }); 157 | }); 158 | }); --------------------------------------------------------------------------------