From f2829b1f8e33d13caae3557d37225d990467fb39 Mon Sep 17 00:00:00 2001 From: Beau Shaw Date: Wed, 29 May 2024 16:00:18 -0500 Subject: [PATCH] fix(mongodb): MongoDB Aggregation improvements (#3366) --- docs/api/databases/mongodb.md | 19 +- packages/mongodb/src/adapter.ts | 392 ++++++++++++++++++++-------- packages/mongodb/test/index.test.ts | 344 ++++++++++++++++++++++++ 3 files changed, 634 insertions(+), 121 deletions(-) diff --git a/docs/api/databases/mongodb.md b/docs/api/databases/mongodb.md index a54876fb7c..37feb704dd 100644 --- a/docs/api/databases/mongodb.md +++ b/docs/api/databases/mongodb.md @@ -66,8 +66,7 @@ MongoDB adapter specific options are: The [common API options](./common.md#options) are: -- `id {string}` (_optional_, default: `'_id'`) - The name of the id field property. By design, MongoDB will always add an `_id` property. -- `id {string}` (_optional_) - The name of the id field property (usually set by default to `id` or `_id`). +- `id {string}` (_optional_, default: `'_id'`) - The name of the id field property. By design, MongoDB will always add an `_id` property. But you can choose to use a different property as your primary key. - `paginate {Object}` (_optional_) - A [pagination object](#pagination) containing a `default` and `max` page size - `multi {string[]|boolean}` (_optional_, default: `false`) - Allow `create` with arrays and `patch` and `remove` with id `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`) @@ -79,19 +78,19 @@ There are additionally several legacy options in the [common API options](./comm ### aggregateRaw(params) -The `find` method has been split into separate utilities for converting params into different types of MongoDB requests. By default, requests are processed by this method and are run through the MongoDB Aggregation Pipeline. This method returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. +The `find` method has been split into separate utilities for converting params into different types of MongoDB requests. When using `params.pipeline`, the `aggregateRaw` method is used to convert the Feathers params into a MongoDB aggregation pipeline with the `model.aggregate` method. This method returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. ### findRaw(params) -`findRaw(params)` is used when `params.mongodb` is set to retrieve data using `params.mongodb` as the `FindOptions` object. This method returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. +`findRaw(params)` This method is used when there is no `params.pipeline` and uses the common `model.find` method. It returns a raw MongoDB Cursor object, which can be used to perform custom pagination or in custom server scripts, if desired. ### makeFeathersPipeline(params) -`makeFeathersPipeline(params)` takes a set of Feathers params and converts them to a pipeline array, ready to pass to `collection.aggregate`. This utility comprises the bulk of the `aggregateRaw` functionality, but does not use `params.pipeline`. +`makeFeathersPipeline(params)` takes a set of Feathers params and converts them to a pipeline array, ready to pass to `model.aggregate`. This utility comprises the bulk of the `aggregateRaw` functionality, but does not use `params.pipeline`. ### Custom Params -The `@feathersjs/mongodb` adapter utilizes two custom params which control adapter-specific features: `params.pipeline` and `params.mongodb`. +The `@feathersjs/mongodb` adapter utilizes three custom params which control adapter-specific features: `params.pipeline`, `params.mongodb`, and `params.adapter`. #### params.adapter @@ -99,11 +98,11 @@ Allows to dynamically set the [adapter options](#options) (like the `Model` coll #### params.pipeline -Used for [aggregation pipelines](#aggregation-pipeline). +Used for [aggregation pipelines](#aggregation-pipeline). Whenever this property is set, the adapter will use the `model.aggregate` method instead of the `model.find` method. The `pipeline` property should be an array of [aggregation stages](https://www.mongodb.com/docs/manual/reference/operator/aggregation-pipeline/). #### params.mongodb -When making a [service method](/api/services.md) call, `params` can contain an`mongodb` property (for example, `{upsert: true}`) which allows modifying the options used to run the MongoDB query. The adapter will use the `collection.find` method and not the [aggregation pipeline](#aggregation-pipeline) when you use `params.mongodb`. +When making a [service method](/api/services.md) call, `params` can contain an`mongodb` property (for example, `{ upsert: true }`) which allows modifying the options used to run the MongoDB query. This param can be used for both find and aggregation queries. ## Transactions @@ -198,9 +197,9 @@ See the MongoDB documentation for instructions on performing full-text search us ## Aggregation Pipeline -In Feathers v5 Dove, we added support for the full power of MongoDB's Aggregation Framework and blends it seamlessly with the familiar Feathers Query syntax. All `find` queries now use the Aggregation Framework, by default. +In Feathers v5 Dove, we added support for the full power of MongoDB's Aggregation Framework and blends it seamlessly with the familiar Feathers Query syntax. The `find` method automatically uses the aggregation pipeline when `params.pipeline` is set. -The Aggregation Framework is accessed through the mongoClient's `collection.aggregate` method, which accepts an array of "stages". Each stage contains an operator which describes an operation to apply to the previous step's data. Each stage applies the operation to the results of the previous step. It’s now possible to perform any of the [Aggregation Stages](https://www.mongodb.com/docs/upcoming/reference/operator/aggregation-pipeline/) like `$lookup` and `$unwind`, integration with the normal Feathers queries. +The Aggregation Framework is accessed through the mongoClient's `model.aggregate` method, which accepts an array of "stages". Each stage contains an operator which describes an operation to apply to the previous step's data. Each stage applies the operation to the results of the previous step. It’s now possible to perform any of the [Aggregation Stages](https://www.mongodb.com/docs/upcoming/reference/operator/aggregation-pipeline/) like `$lookup` and `$unwind`, integration with the normal Feathers queries. Here's how it works with the operators that match the Feathers Query syntax. Let's convert the following Feathers query: diff --git a/packages/mongodb/src/adapter.ts b/packages/mongodb/src/adapter.ts index 5b60b7dd7e..b640bf395d 100644 --- a/packages/mongodb/src/adapter.ts +++ b/packages/mongodb/src/adapter.ts @@ -7,13 +7,15 @@ import { DeleteOptions, CountDocumentsOptions, ReplaceOptions, - Document + FindOneAndReplaceOptions, + FindOneAndUpdateOptions, + Document, + FindOneAndDeleteOptions } from 'mongodb' import { BadRequest, MethodNotAllowed, NotFound } from '@feathersjs/errors' import { _ } from '@feathersjs/commons' import { AdapterBase, - select, AdapterParams, AdapterServiceOptions, PaginationOptions, @@ -39,6 +41,8 @@ export interface MongoDBAdapterParams | DeleteOptions | CountDocumentsOptions | ReplaceOptions + | FindOneAndReplaceOptions + | FindOneAndDeleteOptions } export type AdapterId = Id | ObjectId @@ -103,16 +107,16 @@ export class MongoDbAdapter< async findRaw(params: ServiceParams) { const { filters, query } = this.filterQuery(null, params) const model = await this.getModel(params) - const q = model.find(query, { ...params.mongodb }) - - if (filters.$select !== undefined) { - q.project(this.getSelect(filters.$select)) - } + const q = model.find(query, params.mongodb) if (filters.$sort !== undefined) { q.sort(filters.$sort) } + if (filters.$select !== undefined) { + q.project(this.getProjection(filters.$select)) + } + if (filters.$skip !== undefined) { q.skip(filters.$skip) } @@ -124,6 +128,7 @@ export class MongoDbAdapter< return q } + /* TODO: Remove $out and $merge stages, else it returns an empty cursor. I think its safe to assume this is primarily for querying. */ async aggregateRaw(params: ServiceParams) { const model = await this.getModel(params) const pipeline = params.pipeline || [] @@ -132,21 +137,21 @@ export class MongoDbAdapter< const feathersPipeline = this.makeFeathersPipeline(params) const after = index >= 0 ? pipeline.slice(index + 1) : pipeline - return model.aggregate([...before, ...feathersPipeline, ...after]) + return model.aggregate([...before, ...feathersPipeline, ...after], params.mongodb) } makeFeathersPipeline(params: ServiceParams) { const { filters, query } = this.filterQuery(null, params) const pipeline: Document[] = [{ $match: query }] - if (filters.$select !== undefined) { - pipeline.push({ $project: this.getSelect(filters.$select) }) - } - if (filters.$sort !== undefined) { pipeline.push({ $sort: filters.$sort }) } + if (filters.$select !== undefined) { + pipeline.push({ $project: this.getProjection(filters.$select) }) + } + if (filters.$skip !== undefined) { pipeline.push({ $skip: filters.$skip }) } @@ -154,10 +159,14 @@ export class MongoDbAdapter< if (filters.$limit !== undefined) { pipeline.push({ $limit: filters.$limit }) } + return pipeline } - getSelect(select: string[] | { [key: string]: number }) { + getProjection(select?: string[] | { [key: string]: number }) { + if (!select) { + return undefined + } if (Array.isArray(select)) { if (!select.includes(this.id)) { select = [this.id, ...select] @@ -181,10 +190,6 @@ export class MongoDbAdapter< return select } - async _findOrGet(id: NullableAdapterId, params: ServiceParams) { - return id === null ? await this._find(params) : await this._get(id, params) - } - normalizeId(id: NullableAdapterId, data: D): D { if (this.id === '_id') { // Default Mongo IDs cannot be updated. The Mongo library handles @@ -201,32 +206,77 @@ export class MongoDbAdapter< return data } + async countDocuments(params: ServiceParams) { + const { useEstimatedDocumentCount } = this.getOptions(params) + const { query } = this.filterQuery(null, params) + + if (params.pipeline) { + const aggregateParams = { + ...params, + query: { + ...params.query, + $select: [this.id], + $sort: undefined, + $skip: undefined, + $limit: undefined + } + } + const result = await this.aggregateRaw(aggregateParams).then((result) => result.toArray()) + return result.length + } + + const model = await this.getModel(params) + + if (useEstimatedDocumentCount && typeof model.estimatedDocumentCount === 'function') { + return model.estimatedDocumentCount() + } + + return model.countDocuments(query, params.mongodb) + } + async _get(id: AdapterId, params: ServiceParams = {} as ServiceParams): Promise { const { query, filters: { $select } } = this.filterQuery(id, params) - const projection = $select - ? { - projection: { - ...this.getSelect($select), - [this.id]: 1 - } + + if (params.pipeline) { + const aggregateParams = { + ...params, + query: { + ...params.query, + $limit: 1, + $and: (params.query.$and || []).concat({ + [this.id]: this.getObjectId(id) + }) } - : {} + } + + return this.aggregateRaw(aggregateParams) + .then((result) => result.toArray()) + .then(([result]) => { + if (!result) { + throw new NotFound(`No record found for id '${id}'`) + } + + return result + }) + .catch(errorHandler) + } + const findOptions: FindOptions = { - ...params.mongodb, - ...projection + projection: this.getProjection($select), + ...params.mongodb } return this.getModel(params) .then((model) => model.findOne(query, findOptions)) - .then((data) => { - if (data == null) { + .then((result) => { + if (!result) { throw new NotFound(`No record found for id '${id}'`) } - return data + return result }) .catch(errorHandler) } @@ -235,34 +285,40 @@ export class MongoDbAdapter< async _find(params?: ServiceParams & { paginate: false }): Promise async _find(params?: ServiceParams): Promise | Result[]> async _find(params: ServiceParams = {} as ServiceParams): Promise | Result[]> { - const { paginate, useEstimatedDocumentCount } = this.getOptions(params) - const { filters, query } = this.filterQuery(null, params) - const useAggregation = !params.mongodb && filters.$limit !== 0 - const countDocuments = async () => { - if (paginate && paginate.default) { - const model = await this.getModel(params) - if (useEstimatedDocumentCount && typeof model.estimatedDocumentCount === 'function') { - return model.estimatedDocumentCount() - } else { - return model.countDocuments(query, { ...params.mongodb }) - } + const { paginate } = this.getOptions(params) + const { filters } = this.filterQuery(null, params) + const paginationDisabled = params.paginate === false || !paginate || !paginate.default + + const getData = () => { + const result = params.pipeline ? this.aggregateRaw(params) : this.findRaw(params) + return result.then((result) => result.toArray()) + } + + if (paginationDisabled) { + if (filters.$limit === 0) { + return [] as Result[] + } + const data = await getData() + return data as Result[] + } + + if (filters.$limit === 0) { + return { + total: await this.countDocuments(params), + data: [] as Result[], + limit: filters.$limit, + skip: filters.$skip || 0 } - return Promise.resolve(0) } - const [request, total] = await Promise.all([ - useAggregation ? this.aggregateRaw(params) : this.findRaw(params), - countDocuments() - ]) + const [data, total] = await Promise.all([getData(), this.countDocuments(params)]) - const page = { + return { total, + data: data as Result[], limit: filters.$limit, - skip: filters.$skip || 0, - data: filters.$limit === 0 ? [] : ((await request.toArray()) as any as Result[]) + skip: filters.$skip || 0 } - - return paginate && paginate.default ? page : page.data } async _create(data: Data, params?: ServiceParams): Promise @@ -272,12 +328,14 @@ export class MongoDbAdapter< data: Data | Data[], params: ServiceParams = {} as ServiceParams ): Promise { - const writeOptions = params.mongodb + if (Array.isArray(data) && !this.allowsMulti('create', params)) { + throw new MethodNotAllowed('Can not create multiple entries') + } + const model = await this.getModel(params) const setId = (item: any) => { const entry = Object.assign({}, item) - // Generate a MongoId if we use a custom id if (this.id !== '_id' && typeof entry[this.id] === 'undefined') { return { [this.id]: new ObjectId().toHexString(), @@ -288,17 +346,29 @@ export class MongoDbAdapter< return entry } - const promise = Array.isArray(data) - ? model - .insertMany(data.map(setId), writeOptions) - .then(async (result) => - model.find({ _id: { $in: Object.values(result.insertedIds) } }, params.mongodb).toArray() - ) - : model - .insertOne(setId(data), writeOptions) - .then(async (result) => model.findOne({ _id: result.insertedId }, params.mongodb)) + if (Array.isArray(data)) { + const created = await model.insertMany(data.map(setId), params.mongodb).catch(errorHandler) + return this._find({ + ...params, + paginate: false, + query: { + _id: { $in: Object.values(created.insertedIds) }, + $select: params.query?.$select + } + }) + } - return promise.then(select(params, this.id)).catch(errorHandler) + const created = await model.insertOne(setId(data), params.mongodb).catch(errorHandler) + const result = await this._find({ + ...params, + paginate: false, + query: { + _id: created.insertedId, + $select: params.query?.$select, + $limit: 1 + } + }) + return result[0] } async _patch(id: null, data: PatchData | Partial, params?: ServiceParams): Promise @@ -321,45 +391,91 @@ export class MongoDbAdapter< const model = await this.getModel(params) const { query, - filters: { $select } + filters: { $sort, $select } } = this.filterQuery(id, params) - const updateOptions = { ...params.mongodb } - const modifier = Object.keys(data).reduce((current, key) => { - const value = (data as any)[key] - - if (key.charAt(0) !== '$') { - current.$set = { - ...current.$set, - [key]: value + + const replacement = Object.keys(data).reduce( + (current, key) => { + const value = (data as any)[key] + + if (key.charAt(0) !== '$') { + current.$set[key] = value + } else if (key === '$set') { + current.$set = { + ...current.$set, + ...value + } + } else { + current[key] = value } - } else { - current[key] = value - } - return current - }, {} as any) - const originalIds = await this._findOrGet(id, { - ...params, - query: { - ...query, - $select: [this.id] + return current }, - paginate: false - }) - const items = Array.isArray(originalIds) ? originalIds : [originalIds] - const idList = items.map((item: any) => item[this.id]) - const findParams = { - ...params, - paginate: false, - query: { - [this.id]: { $in: idList }, - $select + { $set: {} } as any + ) + + if (id === null) { + const findParams = { + ...params, + paginate: false, + query: { + ...params.query, + $select: [this.id] + } } + + return this._find(findParams) + .then(async (result) => { + const idList = (result as Result[]).map((item: any) => item[this.id]) + await model.updateMany({ [this.id]: { $in: idList } }, replacement, params.mongodb) + return this._find({ + ...params, + paginate: false, + query: { + [this.id]: { $in: idList }, + $sort, + $select + } + }) + }) + .catch(errorHandler) } - await model.updateMany(query, modifier, updateOptions) + if (params.pipeline) { + const getParams = { + ...params, + query: { + ...params.query, + $select: [this.id] + } + } + + return this._get(id, getParams) + .then(async () => { + await model.updateOne({ [this.id]: id }, replacement, params.mongodb) + return this._get(id, { + ...params, + query: { $select } + }) + }) + .catch(errorHandler) + } + + const updateOptions: FindOneAndUpdateOptions = { + projection: this.getProjection($select), + ...(params.mongodb as FindOneAndUpdateOptions), + returnDocument: 'after' + } - return this._findOrGet(id, findParams).catch(errorHandler) + return model + .findOneAndUpdate(query, replacement, updateOptions) + .then((result) => { + if (!result) { + throw new NotFound(`No record found for id '${id}'`) + } + return result as Result + }) + .catch(errorHandler) } async _update(id: AdapterId, data: Data, params: ServiceParams = {} as ServiceParams): Promise { @@ -367,13 +483,48 @@ export class MongoDbAdapter< throw new BadRequest("You can not replace multiple instances. Did you mean 'patch'?") } + const { + query, + filters: { $select } + } = this.filterQuery(id, params) const model = await this.getModel(params) - const { query } = this.filterQuery(id, params) - const replaceOptions = { ...params.mongodb } + const replacement = this.normalizeId(id, data) + + if (params.pipeline) { + const getParams = { + ...params, + query: { + ...params.query, + $select: [this.id] + } + } - await model.replaceOne(query, this.normalizeId(id, data), replaceOptions) + return this._get(id, getParams) + .then(async () => { + await model.replaceOne({ [this.id]: id }, replacement, params.mongodb) + return this._get(id, { + ...params, + query: { $select } + }) + }) + .catch(errorHandler) + } + + const replaceOptions: FindOneAndReplaceOptions = { + projection: this.getProjection($select), + ...(params.mongodb as FindOneAndReplaceOptions), + returnDocument: 'after' + } - return this._findOrGet(id, params).catch(errorHandler) + return model + .findOneAndReplace(query, replacement, replaceOptions) + .then((result) => { + if (!result) { + throw new NotFound(`No record found for id '${id}'`) + } + return result as Result + }) + .catch(errorHandler) } async _remove(id: null, params?: ServiceParams): Promise @@ -388,24 +539,43 @@ export class MongoDbAdapter< } const model = await this.getModel(params) - const { - query, - filters: { $select } - } = this.filterQuery(id, params) - const deleteOptions = { ...params.mongodb } + const { query } = this.filterQuery(id, params) const findParams = { ...params, - paginate: false, - query: { - ...query, - $select - } + paginate: false + } + + if (id === null) { + return this._find(findParams) + .then(async (result) => { + const idList = (result as Result[]).map((item: any) => item[this.id]) + await model.deleteMany({ [this.id]: { $in: idList } }, params.mongodb) + return result + }) + .catch(errorHandler) + } + + if (params.pipeline) { + return this._get(id, params) + .then(async (result) => { + await model.deleteOne({ [this.id]: id }, params.mongodb) + return result + }) + .catch(errorHandler) } - return this._findOrGet(id, findParams) - .then(async (items) => { - await model.deleteMany(query, deleteOptions) - return items + const deleteOptions: FindOneAndDeleteOptions = { + ...(params.mongodb as FindOneAndDeleteOptions), + projection: this.getProjection(params.query?.$select) + } + + return model + .findOneAndDelete(query, deleteOptions) + .then((result) => { + if (!result) { + throw new NotFound(`No record found for id '${id}'`) + } + return result as Result }) .catch(errorHandler) } diff --git a/packages/mongodb/test/index.test.ts b/packages/mongodb/test/index.test.ts index 6b30394536..7aec6e887d 100644 --- a/packages/mongodb/test/index.test.ts +++ b/packages/mongodb/test/index.test.ts @@ -83,6 +83,11 @@ const testSuite = adapterTests([ 'params.adapter + multi' ]) +const defaultPaginate = { + default: 10, + max: 50 +} + describe('Feathers MongoDB Service', () => { const personSchema = { $id: 'Person', @@ -384,6 +389,96 @@ describe('Feathers MongoDB Service', () => { assert.strictEqual(patched[0].friends?.length, 2) }) + it('can use $limit in patch', async () => { + const data = { name: 'ddd' } + const query = { $limit: 1 } + + const result = await peopleService._patch(null, data, { + query + }) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].name, 'ddd') + + const pipelineResult = await peopleService._patch(null, data, { + pipeline: [], + query + }) + + assert.strictEqual(pipelineResult.length, 1) + assert.strictEqual(pipelineResult[0].name, 'ddd') + }) + + it('can use $limit in remove', async () => { + const query = { $limit: 1 } + + const result = await peopleService._remove(null, { + query + }) + + assert.strictEqual(result.length, 1) + + const pipelineResult = await peopleService._remove(null, { + pipeline: [], + query + }) + + assert.strictEqual(pipelineResult.length, 1) + }) + + it('can use $sort in patch', async () => { + const updated = await peopleService._patch( + null, + { name: 'ddd' }, + { + query: { $limit: 1, $sort: { name: -1 } } + } + ) + + const result = await peopleService.find({ + paginate: false, + query: { $limit: 1, $sort: { name: -1 } } + }) + + assert.strictEqual(updated.length, 1) + assert.strictEqual(result[0].name, 'ddd') + + const pipelineUpdated = await peopleService._patch( + null, + { name: 'eee' }, + { + pipeline: [], + query: { $limit: 1, $sort: { name: -1 } } + } + ) + + const pipelineResult = await peopleService.find({ + paginate: false, + pipeline: [], + query: { $limit: 1, $sort: { name: -1 } } + }) + + assert.strictEqual(pipelineUpdated.length, 1) + assert.strictEqual(pipelineResult[0].name, 'eee') + }) + + it('can use $sort in remove', async () => { + const removed = await peopleService._remove(null, { + query: { $limit: 1, $sort: { name: -1 } } + }) + + assert.strictEqual(removed.length, 1) + assert.strictEqual(removed[0].name, 'ccc') + + const pipelineRemoved = await peopleService._remove(null, { + pipeline: [], + query: { $limit: 1, $sort: { name: -1 } } + }) + + assert.strictEqual(pipelineRemoved.length, 1) + assert.strictEqual(pipelineRemoved[0].name, 'aaa') + }) + it('overrides default index selection using hint param if present', async () => { const indexed = await peopleService.create({ name: 'Indexed', @@ -474,6 +569,175 @@ describe('Feathers MongoDB Service', () => { assert.deepEqual(result[0].person, bob) assert.equal(result.length, 1) }) + + it('can count documents with aggregation', async () => { + const service = app.service('people') + const paginateBefore = service.options.paginate + service.options.paginate = defaultPaginate + const query = { age: { $gte: 25 } } + const findResult = await app.service('people').find({ query }) + const aggregationResult = await app.service('people').find({ query, pipeline: [] }) + + assert.deepStrictEqual(findResult.total, aggregationResult.total) + + service.options.paginate = paginateBefore + }) + + it('can use aggregation in _get', async () => { + const dave = await app.service('people').create({ name: 'Dave', age: 25 }) + const result = await app.service('people').get(dave._id, { + pipeline: [{ $addFields: { aggregation: true } }] + }) + + assert.deepStrictEqual(result, { ...dave, aggregation: true }) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation in _create', async () => { + const dave = (await app.service('people').create( + { name: 'Dave' }, + { + pipeline: [{ $addFields: { aggregation: true } }] + } + )) as any + + assert.deepStrictEqual(dave.aggregation, true) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation in multi _create', async () => { + app.service('people').options.multi = true + const dave = (await app.service('people').create([{ name: 'Dave' }], { + pipeline: [{ $addFields: { aggregation: true } }] + })) as any + + assert.deepStrictEqual(dave[0].aggregation, true) + + app.service('people').options.multi = false + app.service('people').remove(dave[0]._id) + }) + + it('can use aggregation in _update', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').update( + dave._id, + { + name: 'Marshal' + }, + { + pipeline: [{ $addFields: { aggregation: true } }] + } + ) + + assert.deepStrictEqual(result, { ...dave, name: 'Marshal', aggregation: true }) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation in _patch', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').patch( + dave._id, + { + name: 'Marshal' + }, + { + pipeline: [{ $addFields: { aggregation: true } }] + } + ) + + assert.deepStrictEqual(result, { ...dave, name: 'Marshal', aggregation: true }) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation in multi _patch', async () => { + app.service('people').options.multi = true + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').patch( + null, + { + name: 'Marshal' + }, + { + query: { _id: dave._id }, + pipeline: [{ $addFields: { aggregation: true } }] + } + ) + + assert.deepStrictEqual(result[0], { ...dave, name: 'Marshal', aggregation: true }) + + app.service('people').options.multi = false + app.service('people').remove(dave._id) + }) + + it('can use aggregation and query in _update', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').update( + dave._id, + { + name: 'Marshal' + }, + { + query: { name: 'Dave' }, + pipeline: [{ $addFields: { aggregation: true } }] + } + ) + + assert.deepStrictEqual(result, { ...dave, name: 'Marshal', aggregation: true }) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation and query in _patch', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').patch( + dave._id, + { + name: 'Marshal' + }, + { + query: { name: 'Dave' }, + pipeline: [{ $addFields: { aggregation: true } }] + } + ) + + assert.deepStrictEqual(result, { ...dave, name: 'Marshal', aggregation: true }) + + app.service('people').remove(dave._id) + }) + + it('can use aggregation in _remove', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').remove(dave._id, { + pipeline: [{ $addFields: { aggregation: true } }] + }) + + assert.deepStrictEqual(result, { ...dave, aggregation: true }) + + try { + await await app.service('people').get(dave._id) + throw new Error('Should never get here') + } catch (error: any) { + assert.strictEqual(error.name, 'NotFound', 'Got a NotFound Feathers error') + } + }) + + it('can use aggregation in multi _remove', async () => { + app.service('people').options.multi = true + const dave = await app.service('people').create({ name: 'Dave' }) + const result = await app.service('people').remove(null, { + query: { _id: dave._id }, + pipeline: [{ $addFields: { aggregation: true } }] + }) + + assert.deepStrictEqual(result[0], { ...dave, aggregation: true }) + + app.service('people').options.multi = false + // app.service('people').remove(dave._id) + }) }) describe('query validation', () => { @@ -492,6 +756,86 @@ describe('Feathers MongoDB Service', () => { }) }) + // TODO: Should this test be part of the adapterTests? + describe('Updates mutated query', () => { + it('Can re-query mutated data', async () => { + const dave = await app.service('people').create({ name: 'Dave' }) + const dave2 = await app.service('people').create({ name: 'Dave' }) + app.service('people').options.multi = true + + const updated = await app + .service('people') + .update(dave._id, { name: 'Marshal' }, { query: { name: 'Dave' } }) + + assert.deepStrictEqual(updated, { + ...dave, + name: 'Marshal' + }) + + const patched = await app + .service('people') + .patch(dave._id, { name: 'Dave' }, { query: { name: 'Marshal' } }) + + assert.deepStrictEqual(patched, { + ...dave, + name: 'Dave' + }) + + const updatedPipeline = await app + .service('people') + .update(dave._id, { name: 'Marshal' }, { query: { name: 'Dave' }, pipeline: [] }) + + assert.deepStrictEqual(updatedPipeline, { + ...dave, + name: 'Marshal' + }) + + const patchedPipeline = await app + .service('people') + .patch(dave._id, { name: 'Dave' }, { query: { name: 'Marshal' }, pipeline: [] }) + + assert.deepStrictEqual(patchedPipeline, { + ...dave, + name: 'Dave' + }) + + const multiPatch = await app + .service('people') + .patch(null, { name: 'Marshal' }, { query: { name: 'Dave' } }) + + assert.deepStrictEqual(multiPatch, [ + { + ...dave, + name: 'Marshal' + }, + { + ...dave2, + name: 'Marshal' + } + ]) + + const multiPatchPipeline = await app + .service('people') + .patch(null, { name: 'Dave' }, { query: { name: 'Marshal' }, pipeline: [] }) + + assert.deepStrictEqual(multiPatchPipeline, [ + { + ...dave, + name: 'Dave' + }, + { + ...dave2, + name: 'Dave' + } + ]) + + app.service('people').options.multi = false + + app.service('people').remove(dave._id) + app.service('people').remove(dave2._id) + }) + }) + testSuite(app, errors, 'people', '_id') testSuite(app, errors, 'people-customid', 'customid') })