diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index ac83c11dde..dc2f84bc5f 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -841,6 +841,102 @@ describe('DatabaseController', function () { expect(findOneAndUpdateSpy).toHaveBeenCalled(); }); }); + + describe_only_db('mongo')('update with many', () => { + it('should return matchedCount and modifiedCount when multiple docs are updated', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + const obj3 = new Parse.Object('TestObject'); + obj1.set('status', 'pending'); + obj2.set('status', 'pending'); + obj3.set('status', 'pending'); + await Parse.Object.saveAll([obj1, obj2, obj3]); + + const result = await config.database.update( + 'TestObject', + { status: 'pending' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(3); + expect(result.modifiedCount).toBe(3); + }); + + it('should return matchedCount > 0 and modifiedCount 0 when values are already current', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + obj1.set('status', 'done'); + obj2.set('status', 'done'); + await Parse.Object.saveAll([obj1, obj2]); + + const result = await config.database.update( + 'TestObject', + { status: 'done' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(0); + }); + + it('should return matchedCount 0 and modifiedCount 0 when no docs match', async () => { + const config = Config.get(Parse.applicationId); + const result = await config.database.update( + 'TestObject', + { status: 'nonexistent' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(0); + expect(result.modifiedCount).toBe(0); + }); + + it('should return only matchedCount and modifiedCount for op-based updates', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + obj1.set('score', 1); + obj2.set('score', 1); + await Parse.Object.saveAll([obj1, obj2]); + + const result = await config.database.update( + 'TestObject', + { score: { $exists: true } }, + { score: { __op: 'Increment', amount: 5 } }, + { many: true } + ); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(2); + expect(Object.keys(result)).toEqual(['matchedCount', 'modifiedCount']); + }); + + it('should return raw adapter result when skipSanitization is true', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + obj1.set('status', 'pending'); + await obj1.save(); + + const result = await config.database.update( + 'TestObject', + { status: 'pending' }, + { status: 'done' }, + { many: true }, + true // skipSanitization + ); + + // skipSanitization returns raw adapter result, which for MongoDB + // includes additional fields beyond matchedCount and modifiedCount + expect(result.matchedCount).toBe(1); + expect(result.modifiedCount).toBe(1); + expect(result.acknowledged).toBe(true); + }); + }); }); function buildCLP(pointerNames) { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index d25c9753c0..49e1c23d36 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -29,6 +29,11 @@ export type UpdateQueryOptions = { export type FullQueryOptions = QueryOptions & UpdateQueryOptions; +export type UpdateManyResult = { + matchedCount?: number, + modifiedCount?: number, +}; + export interface StorageAdapter { canSortOnJoinTables: boolean; schemaCacheTtl: ?number; @@ -56,13 +61,19 @@ export interface StorageAdapter { query: QueryType, transactionalSession: ?any ): Promise; + /** + * Updates all objects that match the given query. + * Adapters may return an `UpdateManyResult` with optional `matchedCount` and `modifiedCount` + * to indicate how many documents were matched and modified. If not provided, the caller + * receives `undefined` for these fields. + */ updateObjectsByQuery( className: string, schema: SchemaType, query: QueryType, update: any, transactionalSession: ?any - ): Promise<[any]>; + ): Promise<[any] | UpdateManyResult>; findOneAndUpdate( className: string, schema: SchemaType, diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 68e89732f4..c8adc7937d 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -533,6 +533,13 @@ class DatabaseController { }); } + /** + * Updates objects in the database that match the given query. + * @param {Object} options + * @param {boolean} [options.many=false] When true, updates all matching documents + * and returns `{ matchedCount, modifiedCount }` where values are numbers if the + * storage adapter supports `UpdateManyResult`, or `undefined` otherwise. + */ update( className: string, query: any, @@ -701,6 +708,16 @@ class DatabaseController { if (skipSanitization) { return Promise.resolve(result); } + if (many) { + return { + matchedCount: typeof result?.matchedCount === 'number' + ? result.matchedCount + : undefined, + modifiedCount: typeof result?.modifiedCount === 'number' + ? result.modifiedCount + : undefined, + }; + } return this._sanitizeDatabaseResult(originalUpdate, result); }); });