diff --git a/packages/browser/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts b/packages/browser/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts index a69e0aaaa..c77f5fc7d 100644 --- a/packages/browser/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts +++ b/packages/browser/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts @@ -214,7 +214,6 @@ describe("RemoteMongoClient", () => { result = await coll.findOne(); expect(result).toBeDefined(); expect(withoutId(result)).toEqual(withoutId(doc1)); - const doc2 = { hello: "world2", other: "other" }; await coll.insertOne(doc2); @@ -251,6 +250,252 @@ describe("RemoteMongoClient", () => { } }); + it("should find one and update", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndUpdate({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 2 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndUpdate() where we get the previous document back + const update = { + $inc: {num: 1}, + $set: {hello: "hellothere"} + } + result = await coll.findOneAndUpdate({hello: "world"}, update); + expect(withoutId(result)).toEqual({hello: "world", num: 2}); + + // Check to make sure the update took place + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 3}); + + // Call findOneAndUpdate() again but get the new document + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndUpdate( + {hello: "hellotherethisisnotakey"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$set: {hello: "world1", num: 1}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world1", num: 1}); + let count = await coll.count(); + expect(count).toEqual(1); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world2", num: 2}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world2", num: 2}) + count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world3", num: 3}}, + {upsert: true} + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // test sort and project + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world3"}) + + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world1"}) + + // Should properly fail given illegal update doc + try { + await coll.findOneAndUpdate({}, {$who: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.MongoDBError).toEqual(error.errorCode); + } + }); + + it("should find one and replace", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndReplace({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 1 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndReplace() where we get the previous document + result = await coll.findOneAndReplace({hello: "world"}, {hello: "world2", num: 2}) + expect(withoutId(result)).toEqual({hello: "world", num: 1}); + + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world2", num: 2}); + + // Call findOneAndReplace() again but get the new document + result = await coll.findOneAndReplace( + {}, + {hello: "world3", num: 3}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndReplace( + {hello: "hellotherethisisnotakey"}, + {hello: "world4"}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world4", num: 4}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world4", num: 4} ); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world5", num: 5}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world5", num: 5} ); + let count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world6", num: 6}, + {upsert: true}, + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world6"}) + + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world4"}) + + // Should properly fail given an illegal replacement doc with update operations + try { + await coll.findOneAndReplace({}, {$inc: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.InvalidParameter).toEqual(error.errorCode); + } + }); + + it("should find one and delete", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndDelete({}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world1", num: 1 }; + await coll.insertOne(doc1); + let count = await coll.count(); + expect(count).toEqual(1); + + // Simple call to findOneAndDelete() where we delete the only document in the collection + result = await coll.findOneAndDelete({}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but with filter + result = await coll.findOneAndDelete({hello: "world1"}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but give it filter that does not match any documents + result = await coll.findOneAndDelete({hello: "thisdoesntexist"}) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(1); + + // Put in more documents + await coll.insertMany([ + {hello: "world2", num: 2}, + {hello: "world3", num: 3}, + ]); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: -1}}); + expect(result).toEqual({hello: "world3"}) + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: 1}}); + expect(result).toEqual({hello: "world1"}) + }); + it("should aggregate", async () => { const coll = getTestColl(); let iter = coll.aggregate([]); diff --git a/packages/browser/services/mongodb-remote/src/RemoteMongoCollection.ts b/packages/browser/services/mongodb-remote/src/RemoteMongoCollection.ts index 364fa4a93..8a7a094ec 100644 --- a/packages/browser/services/mongodb-remote/src/RemoteMongoCollection.ts +++ b/packages/browser/services/mongodb-remote/src/RemoteMongoCollection.ts @@ -19,6 +19,7 @@ import { ChangeEvent, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -111,6 +112,49 @@ export default interface RemoteMongoCollection { options?: RemoteFindOptions ): Promise; + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + /** * Aggregates documents according to the specified aggregation pipeline. * diff --git a/packages/browser/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts b/packages/browser/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts index 7d8344e0d..812391213 100644 --- a/packages/browser/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts +++ b/packages/browser/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts @@ -20,6 +20,7 @@ import { CoreRemoteMongoCollection, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -95,6 +96,55 @@ export default class RemoteMongoCollectionImpl { return this.proxy.findOne(query, options); } + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndUpdate(query, update, options); + } + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndReplace(query, replacement, options) + } + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + public findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndDelete(query, options); + } + /** * Aggregates documents according to the specified aggregation pipeline. * diff --git a/packages/core/services/mongodb-remote/__tests__/CoreRemoteMongoCollectionUnitTests.ts b/packages/core/services/mongodb-remote/__tests__/CoreRemoteMongoCollectionUnitTests.ts index ac14f817a..e36ae263e 100644 --- a/packages/core/services/mongodb-remote/__tests__/CoreRemoteMongoCollectionUnitTests.ts +++ b/packages/core/services/mongodb-remote/__tests__/CoreRemoteMongoCollectionUnitTests.ts @@ -244,6 +244,275 @@ describe("CoreRemoteMongoCollection", () => { } }); + it("should find one and update", async () => { + const serviceMock = mock(CoreStitchServiceClientImpl); + const service = instance(serviceMock); + + const client = new CoreRemoteMongoClientImpl(service); + const coll = getCollection(undefined, client); + + const doc = { one: 1, two: 2 }; + + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenResolve(doc); + + let result = await coll.findOneAndUpdate({}, {}); + expect(result).toBeDefined(); + expect(result).toEqual(doc); + + const [funcNameArg, funcArgsArg, resultClassArg]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect(funcNameArg).toEqual("findOneAndUpdate"); + expect(funcArgsArg.length).toEqual(1); + const expectedArgs = { + collection: "collName1", + database: "dbName1", + filter: {}, + update: {}, + }; + expect(funcArgsArg[0]).toEqual(expectedArgs); + + const expectedFilter = { one: 1 }; + const expectedUpdate = { $inc: {one: 3} } + const expectedProject = { two: "four" }; + const expectedSort = { _id: -1 }; + let expectedOptions = { + projection : expectedProject, + returnNewDocument: true, + sort: expectedSort, + upsert: true, + } + + result = await coll.findOneAndUpdate(expectedFilter, expectedUpdate, expectedOptions) + expect(result).toEqual(doc); + + verify( + serviceMock.callFunction(anything(), anything(), anything()) + ).times(2); + + const [funcNameArg2, funcArgsArg2, resultClassArg2]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect("findOneAndUpdate").toEqual(funcNameArg2); + expect(1).toEqual(funcArgsArg2.length); + expectedArgs.filter = expectedFilter; + expectedArgs.projection = expectedProject; + expectedArgs.sort = expectedSort; + expectedArgs.update = expectedUpdate; + expectedArgs.upsert = true; + expectedArgs.returnNewDocument = true; + expect(funcArgsArg2[0]).toEqual(expectedArgs); + + expectedOptions = { + projection : expectedProject, + returnNewDocument: false, + sort: expectedSort, + upsert: false, + } + + result = await coll.findOneAndUpdate(expectedFilter, expectedUpdate, expectedOptions) + expect(result).toEqual(doc); + + verify( + serviceMock.callFunction(anything(), anything(), anything()) + ).times(3); + + const [funcNameArg2, funcArgsArg2, resultClassArg2]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect("findOneAndUpdate").toEqual(funcNameArg2); + expect(1).toEqual(funcArgsArg2.length); + delete expectedArgs.upsert; + delete expectedArgs.returnNewDocument; + expect(funcArgsArg2[0]).toEqual(expectedArgs); + + // Should pass along errors + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenReject(new Error("whoops")); + + try { + await coll.findOneAndUpdate({}, {}) + fail(); + } catch (_) { + // Do nothing + } + }); + + it("should find one and replace", async () => { + const serviceMock = mock(CoreStitchServiceClientImpl); + const service = instance(serviceMock); + + const client = new CoreRemoteMongoClientImpl(service); + const coll = getCollection(undefined, client); + + const doc = { one: 1, two: 2 }; + + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenResolve(doc); + + let result = await coll.findOneAndReplace({}, {}); + expect(result).toBeDefined(); + expect(result).toEqual(doc); + + const [funcNameArg, funcArgsArg, resultClassArg]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect(funcNameArg).toEqual("findOneAndReplace"); + expect(funcArgsArg.length).toEqual(1); + const expectedArgs = { + collection: "collName1", + database: "dbName1", + filter: {}, + update: {}, + }; + expect(funcArgsArg[0]).toEqual(expectedArgs); + + const expectedFilter = { one: 1 }; + const expectedUpdate = { hello: 2 } + const expectedProject = { two: "four" }; + const expectedSort = { _id: -1 }; + let expectedOptions = { + projection : expectedProject, + returnNewDocument: true, + sort: expectedSort, + upsert: true, + } + + result = await coll.findOneAndReplace(expectedFilter, expectedUpdate, expectedOptions) + expect(result).toEqual(doc); + + verify( + serviceMock.callFunction(anything(), anything(), anything()) + ).times(2); + + const [funcNameArg2, funcArgsArg2, resultClassArg2]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect("findOneAndReplace").toEqual(funcNameArg2); + expect(1).toEqual(funcArgsArg2.length); + expectedArgs.filter = expectedFilter; + expectedArgs.projection = expectedProject; + expectedArgs.sort = expectedSort; + expectedArgs.update = expectedUpdate; + expectedArgs.upsert = true; + expectedArgs.returnNewDocument = true; + expect(funcArgsArg2[0]).toEqual(expectedArgs); + + expectedOptions = { + projection : expectedProject, + returnNewDocument: false, + sort: expectedSort, + upsert: false, + } + + result = await coll.findOneAndReplace(expectedFilter, expectedUpdate, expectedOptions) + expect(result).toEqual(doc); + + verify( + serviceMock.callFunction(anything(), anything(), anything()) + ).times(3); + + const [funcNameArg2, funcArgsArg2, resultClassArg2]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect("findOneAndReplace").toEqual(funcNameArg2); + expect(1).toEqual(funcArgsArg2.length); + delete expectedArgs.upsert; + delete expectedArgs.returnNewDocument; + expect(funcArgsArg2[0]).toEqual(expectedArgs); + + // Should pass along errors + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenReject(new Error("whoops")); + + try { + await coll.findOneAndReplace({}, {}) + fail(); + } catch (_) { + // Do nothing + } + }); + + it("should find one and delete", async () => { + const serviceMock = mock(CoreStitchServiceClientImpl); + const service = instance(serviceMock); + + const client = new CoreRemoteMongoClientImpl(service); + const coll = getCollection(undefined, client); + + const doc = { one: 1, two: 2 }; + + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenResolve(doc); + + let result = await coll.findOneAndDelete({}); + expect(result).toBeDefined(); + expect(result).toEqual(doc); + + const [funcNameArg, funcArgsArg, resultClassArg]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect(funcNameArg).toEqual("findOneAndDelete"); + expect(funcArgsArg.length).toEqual(1); + const expectedArgs = { + collection: "collName1", + database: "dbName1", + filter: {}, + }; + expect(funcArgsArg[0]).toEqual(expectedArgs); + + const expectedFilter = { one: 1 }; + const expectedProject = { two: "four" }; + const expectedSort = { _id: -1 }; + const expectedOptions = { + projection : expectedProject, + sort: expectedSort, + } + + result = await coll.findOneAndDelete(expectedFilter, expectedOptions) + expect(result).toEqual(doc); + + verify( + serviceMock.callFunction(anything(), anything(), anything()) + ).times(2); + + const [funcNameArg2, funcArgsArg2, resultClassArg2]: any[] = capture( + serviceMock.callFunction + ).last(); + + expect("findOneAndDelete").toEqual(funcNameArg2); + expect(1).toEqual(funcArgsArg2.length); + expectedArgs.filter = expectedFilter; + expectedArgs.projection = expectedProject; + expectedArgs.sort = expectedSort; + expect(funcArgsArg2[0]).toEqual(expectedArgs); + + // Should pass along errors + when( + serviceMock.callFunction(anything(), anything(), anything()) + ).thenReject(new Error("whoops")); + + try { + await coll.findOneAndUpdate({}, {}) + fail(); + } catch (_) { + // Do nothing + } + }); + it("should aggregate", async () => { const serviceMock = mock(CoreStitchServiceClientImpl); const service = instance(serviceMock); diff --git a/packages/core/services/mongodb-remote/src/RemoteFindOneAndModifyOptions.ts b/packages/core/services/mongodb-remote/src/RemoteFindOneAndModifyOptions.ts new file mode 100644 index 000000000..9b0fb4b85 --- /dev/null +++ b/packages/core/services/mongodb-remote/src/RemoteFindOneAndModifyOptions.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2018-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/** + * Options to use when executing a `findOneAndUpdate` command on a + * [[RemoteMongoCollection]]. + * + * @see + * - [[RemoteMongoCollection]] + * - [[RemoteMongoCollection.findOneAndUpdate]] + * - [CRUD Snippets](https://docs.mongodb.com/stitch/mongodb/crud-snippets/#findOneAndUpdate) + */ +export default interface RemoteFindOneAndModifyOptions { + /** + * Optional: Limits the fields to return for all matching documents. See + */ + readonly projection?: object; + + /** + * Optional: Specifies the query sort order. Sort documents specify one or more fields to + * sort on where the value of each field indicates whether MongoDB should sort it in + * ascending (1) or descending (0) order. + * The sort order determines which document collection.findOneAndUpdate() affects. + */ + readonly sort?: object; + + /* + * Optional. Default: false. + * A boolean that, if true, indicates that MongoDB should insert a new document that matches the + * query filter when the query does not match any existing documents in the collection. + */ + readonly upsert?: boolean; + + /* + * Optional. Default: false. + * A boolean that, if true, indicates that the action should return + * the document in its updated form instead of its original, pre-update form. + */ + readonly returnNewDocument?: boolean; + } diff --git a/packages/core/services/mongodb-remote/src/index.ts b/packages/core/services/mongodb-remote/src/index.ts index 7ee576adb..c614631ad 100644 --- a/packages/core/services/mongodb-remote/src/index.ts +++ b/packages/core/services/mongodb-remote/src/index.ts @@ -26,6 +26,7 @@ import MongoNamespace from "./MongoNamespace"; import { OperationType } from "./OperationType"; import RemoteCountOptions from "./RemoteCountOptions"; import RemoteDeleteResult from "./RemoteDeleteResult"; +import RemoteFindOneAndModifyOptions from "./RemoteFindOneAndModifyOptions"; import RemoteFindOptions from "./RemoteFindOptions"; import RemoteInsertManyResult from "./RemoteInsertManyResult"; import RemoteInsertOneResult from "./RemoteInsertOneResult"; @@ -44,6 +45,7 @@ export { RemoteCountOptions, RemoteDeleteResult, RemoteFindOptions, + RemoteFindOneAndModifyOptions, RemoteInsertManyResult, RemoteInsertOneResult, RemoteUpdateOptions, diff --git a/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollection.ts b/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollection.ts index 8de63887d..a7f1d71aa 100644 --- a/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollection.ts +++ b/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollection.ts @@ -18,6 +18,7 @@ import { Codec, Stream } from "mongodb-stitch-core-sdk"; import ChangeEvent from "../ChangeEvent"; import RemoteCountOptions from "../RemoteCountOptions"; import RemoteDeleteResult from "../RemoteDeleteResult"; +import RemoteFindOneAndModifyOptions from "../RemoteFindOneAndModifyOptions"; import RemoteFindOptions from "../RemoteFindOptions"; import RemoteInsertManyResult from "../RemoteInsertManyResult"; import RemoteInsertOneResult from "../RemoteInsertOneResult"; @@ -76,6 +77,53 @@ export default interface CoreRemoteMongoCollection { options?: RemoteFindOptions ): Promise; + /** + * Finds one document in the collection and updates that document. + * + * @param query A document describing the query filter, which may not be null. + * @param update A document describing the update, which may not be null. The update to + * Apply must include only update operators. + * @param options The options to apply to the findOneAndUpdate operation + * + * @return the resulting document or null if no such document exists + */ + findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection and replaces it. + * + * @param query A document describing the query filter, which may not be null. + * @param replacement The document that should replace the found document. + * The document cannot contain any MongoDB update operators. + * @param options The options to apply to the findOneAndReplace operation + * + * @return the resulting document or null if no such document exists + */ + findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection and deletes it. + * + * @param query A document describing the query filter, which may not be null. + * @param replacement The document that should replace the found document. + * The document cannot contain any MongoDB update operators. + * @param options The options to apply to the findOneAndDelete operation + * + * @return the resulting document or null if no such document exists + */ + findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + /** * Aggregates documents according to the specified aggregation pipeline. * diff --git a/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollectionImpl.ts b/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollectionImpl.ts index 1de770728..61fba1f6e 100644 --- a/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollectionImpl.ts +++ b/packages/core/services/mongodb-remote/src/internal/CoreRemoteMongoCollectionImpl.ts @@ -19,6 +19,7 @@ import { Codec, CoreStitchServiceClient, Stream } from "mongodb-stitch-core-sdk" import ChangeEvent from "../ChangeEvent"; import RemoteCountOptions from "../RemoteCountOptions"; import RemoteDeleteResult from "../RemoteDeleteResult"; +import RemoteFindOneAndModifyOptions from "../RemoteFindOneAndModifyOptions"; import RemoteFindOptions from "../RemoteFindOptions"; import RemoteInsertManyResult from "../RemoteInsertManyResult"; import RemoteInsertOneResult from "../RemoteInsertOneResult"; @@ -130,6 +131,125 @@ export default class CoreRemoteMongoCollectionImpl this.codec ); } + + /** + * Finds a document in this collection which matches the provided filter and performs the + * desired updates + * + * - parameters: + * - filter: A `Document` that should match the query. + * - update: A `Document` describing the update. + * - options: Optional `RemoteFindOneAndModifyOptions` to use when executing the command. + * + * - returns: A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndUpdate( + filter: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + const args: any = { ...this.baseOperationArgs }; + + args.filter = filter; + args.update = update; + + if (options) { + if (options.projection) { + args.projection = options.projection; + } + if (options.sort) { + args.sort = options.sort; + } + if (options.upsert) { + args.upsert = true; + } + if (options.returnNewDocument) { + args.returnNewDocument = true; + } + } + return this.service.callFunction( + "findOneAndUpdate", + [args], + this.codec + ); + } + + /** + * Finds a document in this collection which matches the provided filter and replaces it with + * A new document + * + * - parameters: + * - filter: A `Document` that should match the query. + * - replacement: A new `Document` to replace the old one. + * - options: Optional `RemoteFindOneAndModifyOptions` to use when executing the command. + * + * - returns: A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndReplace( + filter: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + const args: any = { ...this.baseOperationArgs }; + + args.filter = filter; + args.update = replacement; + + if (options) { + if (options.projection) { + args.projection = options.projection; + } + if (options.sort) { + args.sort = options.sort; + } + if (options.upsert) { + args.upsert = true; + } + if (options.returnNewDocument) { + args.returnNewDocument = true; + } + } + return this.service.callFunction( + "findOneAndReplace", + [args], + this.codec + ); + } + + /** + * Finds a document in this collection which matches the provided filter and performs the + * desired updates + * + * - parameters: + * - filter: A `Document` that should match the query. + * - update: A `Document` describing the update. + * - options: Optional `RemoteFindOneAndModifyOptions` to use when executing the command. + * + * - returns: A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndDelete( + filter: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + const args: any = { ...this.baseOperationArgs }; + + args.filter = filter; + + if (options) { + if (options.projection) { + args.projection = options.projection; + } + if (options.sort) { + args.sort = options.sort; + } + } + return this.service.callFunction( + "findOneAndDelete", + [args], + this.codec + ); + } + /** * Runs an aggregation framework pipeline against this collection. diff --git a/packages/react-native/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts b/packages/react-native/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts index 8cee69afe..b4fc15b9d 100644 --- a/packages/react-native/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts +++ b/packages/react-native/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts @@ -245,6 +245,252 @@ describe("RemoteMongoClient", () => { } }); + it("should find one and update", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndUpdate({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 2 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndUpdate() where we get the previous document back + const update = { + $inc: {num: 1}, + $set: {hello: "hellothere"} + } + result = await coll.findOneAndUpdate({hello: "world"}, update); + expect(withoutId(result)).toEqual({hello: "world", num: 2}); + + // Check to make sure the update took place + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 3}); + + // Call findOneAndUpdate() again but get the new document + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndUpdate( + {hello: "hellotherethisisnotakey"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$set: {hello: "world1", num: 1}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world1", num: 1}); + let count = await coll.count(); + expect(count).toEqual(1); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world2", num: 2}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world2", num: 2}) + count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world3", num: 3}}, + {upsert: true} + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // test sort and project + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world3"}) + + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world1"}) + + // Should properly fail given illegal update doc + try { + await coll.findOneAndUpdate({}, {$who: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.MongoDBError).toEqual(error.errorCode); + } + }); + + it("should find one and replace", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndReplace({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 1 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndReplace() where we get the previous document + result = await coll.findOneAndReplace({hello: "world"}, {hello: "world2", num: 2}) + expect(withoutId(result)).toEqual({hello: "world", num: 1}); + + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world2", num: 2}); + + // Call findOneAndReplace() again but get the new document + result = await coll.findOneAndReplace( + {}, + {hello: "world3", num: 3}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndReplace( + {hello: "hellotherethisisnotakey"}, + {hello: "world4"}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world4", num: 4}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world4", num: 4} ); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world5", num: 5}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world5", num: 5} ); + let count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world6", num: 6}, + {upsert: true}, + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world6"}) + + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world4"}) + + // Should properly fail given an illegal replacement doc with update operations + try { + await coll.findOneAndReplace({}, {$inc: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.InvalidParameter).toEqual(error.errorCode); + } + }); + + it("should find one and delete", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndDelete({}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world1", num: 1 }; + await coll.insertOne(doc1); + let count = await coll.count(); + expect(count).toEqual(1); + + // Simple call to findOneAndDelete() where we delete the only document in the collection + result = await coll.findOneAndDelete({}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but with filter + result = await coll.findOneAndDelete({hello: "world1"}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but give it filter that does not match any documents + result = await coll.findOneAndDelete({hello: "thisdoesntexist"}) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(1); + + // Put in more documents + await coll.insertMany([ + {hello: "world2", num: 2}, + {hello: "world3", num: 3}, + ]); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: -1}}); + expect(result).toEqual({hello: "world3"}) + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: 1}}); + expect(result).toEqual({hello: "world1"}) + }); + it("should aggregate", async () => { const coll = getTestColl(); let iter = coll.aggregate([]); diff --git a/packages/react-native/services/mongodb-remote/src/RemoteMongoCollection.ts b/packages/react-native/services/mongodb-remote/src/RemoteMongoCollection.ts index 9e196969d..12e38c535 100644 --- a/packages/react-native/services/mongodb-remote/src/RemoteMongoCollection.ts +++ b/packages/react-native/services/mongodb-remote/src/RemoteMongoCollection.ts @@ -19,6 +19,7 @@ import { ChangeEvent, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -108,6 +109,49 @@ export default interface RemoteMongoCollection { options?: RemoteFindOptions ): Promise; + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + /** * Aggregates documents according to the specified aggregation pipeline. * diff --git a/packages/react-native/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts b/packages/react-native/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts index 33f599b09..8a9fad6c7 100644 --- a/packages/react-native/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts +++ b/packages/react-native/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts @@ -20,6 +20,7 @@ import { CoreRemoteMongoCollection, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -82,6 +83,55 @@ export default class RemoteMongoCollectionImpl { ); } + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndUpdate(query, update, options); + } + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndReplace(query, replacement, options) + } + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + public findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndDelete(query, options); + } + /** * Finds one document in the collection. * diff --git a/packages/server/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts b/packages/server/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts index c8131d9b8..2df2f5216 100644 --- a/packages/server/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts +++ b/packages/server/services/mongodb-remote/__tests__/RemoteMongoClientIntTests.ts @@ -244,6 +244,252 @@ describe("RemoteMongoClient", () => { } }); + it("should find one and update", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndUpdate({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 2 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndUpdate() where we get the previous document back + const update = { + $inc: {num: 1}, + $set: {hello: "hellothere"} + } + result = await coll.findOneAndUpdate({hello: "world"}, update); + expect(withoutId(result)).toEqual({hello: "world", num: 2}); + + // Check to make sure the update took place + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 3}); + + // Call findOneAndUpdate() again but get the new document + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "hellothere", num: 4}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndUpdate( + {hello: "hellotherethisisnotakey"}, + {$inc: {num: 1}}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndUpdate( + {hello: "hellothere"}, + {$set: {hello: "world1", num: 1}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world1", num: 1}); + let count = await coll.count(); + expect(count).toEqual(1); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world2", num: 2}}, + {upsert: true, returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world2", num: 2}) + count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndUpdate( + {hello: "hello"}, + {$set: {hello: "world3", num: 3}}, + {upsert: true} + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // test sort and project + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world3"}) + + result = await coll.findOneAndUpdate( + {}, + {$inc: {num: 1}}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world1"}) + + // Should properly fail given illegal update doc + try { + await coll.findOneAndUpdate({}, {$who: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.MongoDBError).toEqual(error.errorCode); + } + }); + + it("should find one and replace", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndReplace({}, {}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world", num: 1 }; + await coll.insertOne(doc1); + + // Simple call to findOneAndReplace() where we get the previous document + result = await coll.findOneAndReplace({hello: "world"}, {hello: "world2", num: 2}) + expect(withoutId(result)).toEqual({hello: "world", num: 1}); + + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world2", num: 2}); + + // Call findOneAndReplace() again but get the new document + result = await coll.findOneAndReplace( + {}, + {hello: "world3", num: 3}, + {returnNewDocument: true} + ) + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + result = await coll.findOne(); + expect(withoutId(result)).toEqual({hello: "world3", num: 3}); + + // Test null behavior again with filter that should not match any docs + result = await coll.findOneAndReplace( + {hello: "hellotherethisisnotakey"}, + {hello: "world4"}, + {returnNewDocument: true} + ) + expect(result).toBeNull(); + + // Test the upsert option where it should not actually be invoked + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world4", num: 4}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world4", num: 4} ); + + // Test the upsert option where the server should perform upsert and return new document + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world5", num: 5}, + {upsert: true, returnNewDocument: true}, + ) + expect(withoutId(result)).toEqual( {hello: "world5", num: 5} ); + let count = await coll.count(); + expect(count).toEqual(2); + + // Test the upsert option where the server should perform upsert and return old document + // The old document should be empty + result = await coll.findOneAndReplace( + {hello: "world3"}, + {hello: "world6", num: 6}, + {upsert: true}, + ) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: -1}} + ) + expect(result).toEqual({hello: "world6"}) + + result = await coll.findOneAndReplace( + {}, + {hello: "oldworld", num: 100}, + {projection: {hello: 1, _id: 0}, sort: {num: 1}} + ) + expect(result).toEqual({hello: "world4"}) + + // Should properly fail given an illegal replacement doc with update operations + try { + await coll.findOneAndReplace({}, {$inc: {a: 1}}) + fail(); + } catch (error) { + expect(error instanceof StitchServiceError).toBeTruthy(); + expect(StitchServiceErrorCode.InvalidParameter).toEqual(error.errorCode); + } + }); + + it("should find one and delete", async () => { + const coll = getTestColl(); + + // Collection should start out empty + // This also tests the null return format + let result = await coll.findOneAndDelete({}); + expect(result).toBeNull(); + + // Insert Sample Document + const doc1 = { hello: "world1", num: 1 }; + await coll.insertOne(doc1); + let count = await coll.count(); + expect(count).toEqual(1); + + // Simple call to findOneAndDelete() where we delete the only document in the collection + result = await coll.findOneAndDelete({}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but with filter + result = await coll.findOneAndDelete({hello: "world1"}) + expect(withoutId(result)).toEqual({ hello: "world1", num: 1 }); + count = await coll.count(); + expect(count).toEqual(0); + + // Insert Sample Document + await coll.insertOne(doc1); + count = await coll.count(); + expect(count).toEqual(1); + + // Call findOneAndDelete() again but give it filter that does not match any documents + result = await coll.findOneAndDelete({hello: "thisdoesntexist"}) + expect(result).toBeNull(); + count = await coll.count(); + expect(count).toEqual(1); + + // Put in more documents + await coll.insertMany([ + {hello: "world2", num: 2}, + {hello: "world3", num: 3}, + ]); + count = await coll.count(); + expect(count).toEqual(3); + + // Test sort and project + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: -1}}); + expect(result).toEqual({hello: "world3"}) + result = await coll.findOneAndDelete({}, {projection: {hello: 1, _id: 0}, sort: {num: 1}}); + expect(result).toEqual({hello: "world1"}) + }); + it("should aggregate", async () => { const coll = getTestColl(); let iter = coll.aggregate([]); diff --git a/packages/server/services/mongodb-remote/src/RemoteMongoCollection.ts b/packages/server/services/mongodb-remote/src/RemoteMongoCollection.ts index c5fd9d270..f1280c5c1 100644 --- a/packages/server/services/mongodb-remote/src/RemoteMongoCollection.ts +++ b/packages/server/services/mongodb-remote/src/RemoteMongoCollection.ts @@ -19,6 +19,7 @@ import { ChangeEvent, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -108,6 +109,50 @@ export default interface RemoteMongoCollection { options?: RemoteFindOptions ): Promise; + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise; + + /** * Aggregates documents according to the specified aggregation pipeline. * diff --git a/packages/server/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts b/packages/server/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts index b4cab2070..6ed5bc636 100644 --- a/packages/server/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts +++ b/packages/server/services/mongodb-remote/src/internal/RemoteMongoCollectionImpl.ts @@ -20,6 +20,7 @@ import { CoreRemoteMongoCollection, RemoteCountOptions, RemoteDeleteResult, + RemoteFindOneAndModifyOptions, RemoteFindOptions, RemoteInsertManyResult, RemoteInsertOneResult, @@ -95,6 +96,55 @@ export default class RemoteMongoCollectionImpl { return this.proxy.findOne(query, options); } + /** + * Finds one document in the collection that matches the given query and performs the + * given update on that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param update A `Document` describing the update. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndUpdate( + query: object, + update: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndUpdate(query, update, options); + } + + /** + * Finds one document in the collection that matches the given query and replaces that document + * with the given replacement. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param replacement A `Document` that will replace the matched document + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return A resulting `DocumentT` or null if the query returned zero matches. + */ + public findOneAndReplace( + query: object, + replacement: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndReplace(query, replacement, options) + } + + /** + * Finds one document in the collection that matches the given query and + * deletes that document. (An empty query {} will match all documents) + * + * @param query A `Document` that should match the query. + * @param options Optional: `RemoteFindOneAndModifyOptions` to use when executing the command. + * @return The `DocumentT` being deleted or null if the query returned zero matches. + */ + public findOneAndDelete( + query: object, + options?: RemoteFindOneAndModifyOptions + ): Promise { + return this.proxy.findOneAndDelete(query, options); + } + /** * Aggregates documents according to the specified aggregation pipeline. *