diff --git a/spec/ParseGraphQLController.spec.js b/spec/ParseGraphQLController.spec.js index 15bbb48ab7..54784d14ac 100644 --- a/spec/ParseGraphQLController.spec.js +++ b/spec/ParseGraphQLController.spec.js @@ -872,6 +872,32 @@ describe('ParseGraphQLController', () => { ], }) ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + createMany: 1, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + createMany: true, + updateMany: true, + deleteMany: false, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); }); it('should throw if _User create fields is missing username or password', async () => { @@ -1043,6 +1069,54 @@ describe('ParseGraphQLController', () => { ).toBeRejected( `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.destroyAlias" must be a string` ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + createMany: true, + createManyAlias: true, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.createManyAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + updateMany: true, + updateManyAlias: 1, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.updateManyAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + deleteMany: true, + deleteManyAlias: { not: 'valid' }, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.deleteManyAlias" must be a string` + ); }); }); }); diff --git a/spec/ParseGraphQLSchema.spec.js b/spec/ParseGraphQLSchema.spec.js index 0b3d9a9007..1ac3362d28 100644 --- a/spec/ParseGraphQLSchema.spec.js +++ b/spec/ParseGraphQLSchema.spec.js @@ -495,7 +495,16 @@ describe('ParseGraphQLSchema', () => { expect(Object.keys(queries1).sort()).toEqual(Object.keys(queries2).sort()); expect(mutations1).not.toBe(mutations2); expect( - Object.keys(mutations1).concat('createCars', 'updateCars', 'deleteCars').sort() + Object.keys(mutations1) + .concat( + 'createCars', + 'updateCars', + 'deleteCars', + 'createManyCars', + 'updateManyCars', + 'deleteManyCars' + ) + .sort() ).toEqual(Object.keys(mutations2).sort()); }); }); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 976e58fcda..83582cca3f 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -11788,6 +11788,860 @@ describe('ParseGraphQLServer', () => { expect(getResult.data.get.objectId).toEqual(product.id); }); + + describe('Bulk class mutations', () => { + beforeEach(async () => { + const sc = new Parse.Schema('BulkTest'); + await sc.purge().catch(() => {}); + await sc.delete().catch(() => {}); + await sc.addString('title').save(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + }); + + async function reconfigureGraphQLWithBatchLimit2AndOpenClient() { + parseServer = await global.reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + await createGQLFromParseServer(parseServer); + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + const sc = new Parse.Schema('BulkTest'); + await sc.purge().catch(() => {}); + await sc.delete().catch(() => {}); + await sc.addString('title').save(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + await updateCLP( + { + create: { '*': true }, + find: { '*': true }, + get: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + }, + 'BulkTest' + ); + } + + async function reconfigureGraphQLWithUnsanitizedErrorsAndOpenClient() { + parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + enableSanitizedErrorResponse: false, + }); + await createGQLFromParseServer(parseServer); + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + const sc = new Parse.Schema('BulkTest'); + await sc.purge().catch(() => {}); + await sc.delete().catch(() => {}); + await sc.addString('title').save(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + await updateCLP( + { + create: { '*': true }, + find: { '*': true }, + get: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + }, + 'BulkTest' + ); + } + + const clientKeyHeaders = { + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }; + + it('should reject createMany when item count exceeds batchRequestLimit', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyOverLimit($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + } + } + `, + variables: { + input: { + clientMutationId: uuidv4(), + fields: [{ title: 'a' }, { title: 'b' }, { title: 'c' }], + }, + }, + context: clientKeyHeaders, + errorPolicy: 'all', + }); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain('exceeds the limit of 2'); + } catch (e) { + handleError(e); + } + }); + + it('should reject updateMany when item count exceeds batchRequestLimit', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + const objs = []; + for (let i = 0; i < 3; i++) { + const o = new Parse.Object('BulkTest'); + o.set('title', `t${i}`); + await o.save(null, { useMasterKey: true }); + objs.push(o); + } + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation UpdateManyOverLimit($input: UpdateManyBulkTestInput!) { + updateManyBulkTest(input: $input) { + clientMutationId + } + } + `, + variables: { + input: { + clientMutationId: uuidv4(), + updates: objs.map((o, i) => ({ + id: toGlobalId('BulkTest', o.id), + fields: { title: `u${i}` }, + })), + }, + }, + context: clientKeyHeaders, + errorPolicy: 'all', + }); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain('exceeds the limit of 2'); + } catch (e) { + handleError(e); + } + }); + + it('should reject deleteMany when item count exceeds batchRequestLimit', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + const objs = []; + for (let i = 0; i < 3; i++) { + const o = new Parse.Object('BulkTest'); + o.set('title', `d${i}`); + await o.save(null, { useMasterKey: true }); + objs.push(o); + } + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation DeleteManyOverLimit($input: DeleteManyBulkTestInput!) { + deleteManyBulkTest(input: $input) { + clientMutationId + } + } + `, + variables: { + input: { + clientMutationId: uuidv4(), + ids: objs.map(o => toGlobalId('BulkTest', o.id)), + }, + }, + context: clientKeyHeaders, + errorPolicy: 'all', + }); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toContain('exceeds the limit of 2'); + } catch (e) { + handleError(e); + } + }); + + it('should allow createMany at exactly batchRequestLimit with client key', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyAtLimit($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + results { + success + bulkTest { + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: [{ title: 'one' }, { title: 'two' }], + }, + }, + context: clientKeyHeaders, + }); + expect(data.createManyBulkTest.results.length).toBe(2); + expect(data.createManyBulkTest.results.every(r => r.success)).toBe(true); + } catch (e) { + handleError(e); + } + }); + + it('should bypass batchRequestLimit for master key on createMany', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyMasterBypass($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + results { + success + bulkTest { + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: [{ title: 'm1' }, { title: 'm2' }, { title: 'm3' }], + }, + }, + context: { + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(data.createManyBulkTest.results.length).toBe(3); + expect(data.createManyBulkTest.results.every(r => r.success)).toBe(true); + } catch (e) { + handleError(e); + } + }); + + it('should createMany with ordered successes', async () => { + try { + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyBulk($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + id + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: [{ title: 'first' }, { title: 'second' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(data.createManyBulkTest.results.length).toBe(2); + expect(data.createManyBulkTest.results[0].success).toBe(true); + expect(data.createManyBulkTest.results[0].bulkTest.title).toBe('first'); + expect(data.createManyBulkTest.results[1].success).toBe(true); + expect(data.createManyBulkTest.results[1].bulkTest.title).toBe('second'); + } catch (e) { + handleError(e); + } + }); + + it('should updateMany with mixed success and failure', async () => { + try { + const a = new Parse.Object('BulkTest'); + a.set('title', 'a'); + const b = new Parse.Object('BulkTest'); + b.set('title', 'b'); + await Parse.Object.saveAll([a, b]); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation UpdateManyBulk($input: UpdateManyBulkTestInput!) { + updateManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + updates: [ + { id: toGlobalId('BulkTest', a.id), fields: { title: 'a2' } }, + { id: 'nonexistentid000', fields: { title: 'x' } }, + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(data.updateManyBulkTest.results.length).toBe(2); + expect(data.updateManyBulkTest.results[0].success).toBe(true); + expect(data.updateManyBulkTest.results[0].bulkTest.title).toBe('a2'); + expect(data.updateManyBulkTest.results[1].success).toBe(false); + expect(data.updateManyBulkTest.results[1].error).toBeDefined(); + expect(data.updateManyBulkTest.results[1].bulkTest).toBeNull(); + } catch (e) { + handleError(e); + } + }); + + it('should deleteMany', async () => { + try { + const a = new Parse.Object('BulkTest'); + a.set('title', 'del1'); + const b = new Parse.Object('BulkTest'); + b.set('title', 'del2'); + await Parse.Object.saveAll([a, b]); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation DeleteManyBulk($input: DeleteManyBulkTestInput!) { + deleteManyBulkTest(input: $input) { + clientMutationId + results { + success + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + ids: [toGlobalId('BulkTest', a.id), toGlobalId('BulkTest', b.id)], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(data.deleteManyBulkTest.results.length).toBe(2); + expect(data.deleteManyBulkTest.results[0].success).toBe(true); + expect(data.deleteManyBulkTest.results[1].success).toBe(true); + + const dup = new Parse.Object('BulkTest'); + dup.set('title', 'dupDel'); + await dup.save(null, { useMasterKey: true }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const dupClientMutationId = uuidv4(); + const { data: dupData } = await apolloClient.mutate({ + mutation: gql` + mutation DeleteManyBulkDup($input: DeleteManyBulkTestInput!) { + deleteManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId: dupClientMutationId, + ids: [ + toGlobalId('BulkTest', dup.id), + toGlobalId('BulkTest', dup.id), + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + const dupResults = dupData.deleteManyBulkTest.results; + expect(dupResults.length).toBe(2); + expect(dupResults.filter(r => r.success).length).toBe(1); + expect(dupResults.filter(r => !r.success).length).toBe(1); + expect(dupResults.find(r => !r.success).error.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(dupResults.find(r => r.success).bulkTest.title).toBe('dupDel'); + + const qDup = new Parse.Query('BulkTest'); + qDup.equalTo('objectId', dup.id); + const dupRemaining = await qDup.find({ useMasterKey: true }); + expect(dupRemaining.length).toBe(0); + } catch (e) { + handleError(e); + } + }); + + it('should createMany with partial failure when beforeSave rejects', async () => { + try { + Parse.Cloud.beforeSave('BulkTest', request => { + if (request.object.get('title') === 'FAIL') { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'beforeSave blocked this title' + ); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyBulkPartial($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: [{ title: 'ok' }, { title: 'FAIL' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const results = data.createManyBulkTest.results; + expect(results.length).toBe(2); + expect(results[0].success).toBe(true); + expect(results[0].bulkTest.title).toBe('ok'); + expect(results[0].error).toBeNull(); + expect(results[1].success).toBe(false); + expect(results[1].bulkTest).toBeNull(); + expect(results[1].error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(results[1].error.message).toBe('Permission denied'); + + const q = new Parse.Query('BulkTest'); + q.equalTo('title', 'ok'); + const saved = await q.find({ useMasterKey: true }); + expect(saved.length).toBe(1); + } catch (e) { + handleError(e); + } + }); + + it('should return detailed Parse.Error message in createMany bulk when enableSanitizedErrorResponse is false', async () => { + try { + await reconfigureGraphQLWithUnsanitizedErrorsAndOpenClient(); + + Parse.Cloud.beforeSave('BulkTest', request => { + if (request.object.get('title') === 'FAIL') { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'beforeSave blocked this title' + ); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyBulkUnsanitized($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: [{ title: 'ok' }, { title: 'FAIL' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const results = data.createManyBulkTest.results; + expect(results.length).toBe(2); + expect(results[1].success).toBe(false); + expect(results[1].error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(results[1].error.message).toBe('beforeSave blocked this title'); + } catch (e) { + handleError(e); + } + }); + + it('should sanitize non-Parse Error messages in createMany bulk when sanitization is enabled', async () => { + try { + await reconfigureGraphQLWithBatchLimit2AndOpenClient(); + + Parse.Cloud.beforeSave('BulkTest', request => { + if (request.object.get('title') === 'FAIL') { + throw new Error('internal stack detail'); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyBulkPlainErrorSanitized($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId: uuidv4(), + fields: [{ title: 'ok' }, { title: 'FAIL' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const [ok, failed] = data.createManyBulkTest.results; + expect(ok.success).toBe(true); + expect(failed.success).toBe(false); + // Cloud Code wraps a plain Error as Parse.Error(SCRIPT_FAILED) before bulk handling; + // sanitized response matches other Parse.Error bulk failures. + expect(failed.error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(failed.error.message).toBe('Permission denied'); + } catch (e) { + handleError(e); + } + }); + + it('should return raw non-Parse Error message in createMany bulk when sanitization is disabled', async () => { + try { + await reconfigureGraphQLWithUnsanitizedErrorsAndOpenClient(); + + Parse.Cloud.beforeSave('BulkTest', request => { + if (request.object.get('title') === 'FAIL') { + throw new Error('internal stack detail'); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation CreateManyBulkPlainErrorUnsanitized($input: CreateManyBulkTestInput!) { + createManyBulkTest(input: $input) { + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId: uuidv4(), + fields: [{ title: 'ok' }, { title: 'FAIL' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const failed = data.createManyBulkTest.results[1]; + expect(failed.success).toBe(false); + expect(failed.error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(failed.error.message).toBe('internal stack detail'); + } catch (e) { + handleError(e); + } + }); + + it('should updateMany with partial failure when beforeSave rejects', async () => { + try { + const a = new Parse.Object('BulkTest'); + a.set('title', 'a'); + const b = new Parse.Object('BulkTest'); + b.set('title', 'b'); + await Parse.Object.saveAll([a, b]); + + Parse.Cloud.beforeSave('BulkTest', request => { + if (request.object.get('title') === 'BLOCKED') { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'beforeSave blocked update to BLOCKED' + ); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation UpdateManyBulkPartial($input: UpdateManyBulkTestInput!) { + updateManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + updates: [ + { id: toGlobalId('BulkTest', a.id), fields: { title: 'a2' } }, + { id: toGlobalId('BulkTest', b.id), fields: { title: 'BLOCKED' } }, + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const results = data.updateManyBulkTest.results; + expect(results.length).toBe(2); + expect(results[0].success).toBe(true); + expect(results[0].bulkTest.title).toBe('a2'); + expect(results[0].error).toBeNull(); + expect(results[1].success).toBe(false); + expect(results[1].bulkTest).toBeNull(); + expect(results[1].error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(results[1].error.message).toBe('Permission denied'); + + await a.fetch({ useMasterKey: true }); + await b.fetch({ useMasterKey: true }); + expect(a.get('title')).toBe('a2'); + expect(b.get('title')).toBe('b'); + } catch (e) { + handleError(e); + } + }); + + it('should deleteMany with partial failure when beforeDelete rejects', async () => { + try { + const a = new Parse.Object('BulkTest'); + a.set('title', 'deletable'); + const b = new Parse.Object('BulkTest'); + b.set('title', 'nodelete'); + await Parse.Object.saveAll([a, b]); + + Parse.Cloud.beforeDelete('BulkTest', request => { + if (request.object.get('title') === 'nodelete') { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'beforeDelete blocked delete for nodelete' + ); + } + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const clientMutationId = uuidv4(); + const { data } = await apolloClient.mutate({ + mutation: gql` + mutation DeleteManyBulkPartial($input: DeleteManyBulkTestInput!) { + deleteManyBulkTest(input: $input) { + clientMutationId + results { + success + error { + code + message + } + bulkTest { + objectId + title + } + } + } + } + `, + variables: { + input: { + clientMutationId, + ids: [ + toGlobalId('BulkTest', a.id), + toGlobalId('BulkTest', a.id), + toGlobalId('BulkTest', b.id), + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const results = data.deleteManyBulkTest.results; + expect(results.length).toBe(3); + expect(results.filter(r => r.success).length).toBe(1); + expect( + results.filter(r => !r.success && r.error.code === Parse.Error.OBJECT_NOT_FOUND) + .length + ).toBe(1); + expect( + results.filter(r => !r.success && r.error.code === Parse.Error.SCRIPT_FAILED).length + ).toBe(1); + const deleted = results.find(r => r.success); + expect(deleted.bulkTest.title).toBe('deletable'); + expect(deleted.error).toBeNull(); + const notFound = results.find( + r => !r.success && r.error.code === Parse.Error.OBJECT_NOT_FOUND + ); + expect(notFound.bulkTest).toBeNull(); + const blocked = results.find( + r => !r.success && r.error.code === Parse.Error.SCRIPT_FAILED + ); + expect(blocked.bulkTest).toBeNull(); + expect(blocked.error.message).toBe('Permission denied'); + + const q = new Parse.Query('BulkTest'); + const remaining = await q.find({ useMasterKey: true }); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe(b.id); + expect(remaining[0].get('title')).toBe('nodelete'); + } catch (e) { + handleError(e); + } + }); + }); }); }); }); @@ -12217,6 +13071,7 @@ describe('ParseGraphQLServer', () => { expect(result3.data.updateSomeClass.someClass.type).toEqual('human'); }); }); + describe('Async Function Based Merge', () => { let httpServer; const headers = { diff --git a/spec/Utils.spec.js b/spec/Utils.spec.js index f6d24b55f9..face2ceec1 100644 --- a/spec/Utils.spec.js +++ b/spec/Utils.spec.js @@ -1,5 +1,5 @@ const Utils = require('../lib/Utils'); -const { createSanitizedError, createSanitizedHttpError } = require("../lib/Error") +const { createSanitizedError, createSanitizedHttpError, bulkErrorPayloadFromReason } = require("../lib/Error") const vm = require('vm'); describe('Utils', () => { @@ -289,6 +289,64 @@ describe('Utils', () => { }); }); + describe('bulkErrorPayloadFromReason', () => { + it('should sanitize Parse.Error messages when enableSanitizedErrorResponse is true', () => { + const config = { enableSanitizedErrorResponse: true }; + const reason = new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Cloud script detail'); + const payload = bulkErrorPayloadFromReason(reason, config); + expect(payload.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(payload.message).toBe('Permission denied'); + }); + + it('should return detailed Parse.Error messages when enableSanitizedErrorResponse is false', () => { + const config = { enableSanitizedErrorResponse: false }; + const reason = new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Cloud script detail'); + const payload = bulkErrorPayloadFromReason(reason, config); + expect(payload.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(payload.message).toBe('Cloud script detail'); + }); + + it('should sanitize non-Parse reasons', () => { + const config = { enableSanitizedErrorResponse: true }; + const payload = bulkErrorPayloadFromReason(new Error('internal stack trace'), config); + expect(payload.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(payload.message).toBe('Internal server error'); + }); + + it('should return non-Parse message when enableSanitizedErrorResponse is false', () => { + const config = { enableSanitizedErrorResponse: false }; + const payload = bulkErrorPayloadFromReason(new Error('internal stack trace'), config); + expect(payload.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(payload.message).toBe('internal stack trace'); + }); + + it('should not throw when reason.message getter throws', () => { + const reason = {}; + Object.defineProperty(reason, 'message', { + get() { + throw new Error('boom'); + }, + configurable: true, + }); + const sanitized = bulkErrorPayloadFromReason(reason, { enableSanitizedErrorResponse: true }); + expect(sanitized.message).toBe('Internal server error'); + const detailed = bulkErrorPayloadFromReason(reason, { enableSanitizedErrorResponse: false }); + expect(detailed.message).toBe('Internal server error'); + }); + + it('should not throw when String(reason) would throw', () => { + const reason = { + toString() { + throw new Error('boom'); + }, + }; + const sanitized = bulkErrorPayloadFromReason(reason, { enableSanitizedErrorResponse: true }); + expect(sanitized.message).toBe('Internal server error'); + const detailed = bulkErrorPayloadFromReason(reason, { enableSanitizedErrorResponse: false }); + expect(detailed.message).toBe('Internal server error'); + }); + }); + describe('isDate', () => { it('should return true for a Date', () => { expect(Utils.isDate(new Date())).toBe(true); diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js index 4445f4763c..f48845fa0a 100644 --- a/src/Controllers/ParseGraphQLController.js +++ b/src/Controllers/ParseGraphQLController.js @@ -258,9 +258,15 @@ class ParseGraphQLController { create = null, update = null, destroy = null, + createMany = null, + updateMany = null, + deleteMany = null, createAlias = null, updateAlias = null, destroyAlias = null, + createManyAlias = null, + updateManyAlias = null, + deleteManyAlias = null, ...invalidKeys } = mutation; if (Object.keys(invalidKeys).length) { @@ -275,6 +281,15 @@ class ParseGraphQLController { if (destroy !== null && typeof destroy !== 'boolean') { return `"mutation.destroy" must be a boolean`; } + if (createMany !== null && typeof createMany !== 'boolean') { + return `"mutation.createMany" must be a boolean`; + } + if (updateMany !== null && typeof updateMany !== 'boolean') { + return `"mutation.updateMany" must be a boolean`; + } + if (deleteMany !== null && typeof deleteMany !== 'boolean') { + return `"mutation.deleteMany" must be a boolean`; + } if (createAlias !== null && typeof createAlias !== 'string') { return `"mutation.createAlias" must be a string`; } @@ -284,6 +299,15 @@ class ParseGraphQLController { if (destroyAlias !== null && typeof destroyAlias !== 'string') { return `"mutation.destroyAlias" must be a string`; } + if (createManyAlias !== null && typeof createManyAlias !== 'string') { + return `"mutation.createManyAlias" must be a string`; + } + if (updateManyAlias !== null && typeof updateManyAlias !== 'string') { + return `"mutation.updateManyAlias" must be a string`; + } + if (deleteManyAlias !== null && typeof deleteManyAlias !== 'string') { + return `"mutation.deleteManyAlias" must be a string`; + } } else { return `"mutation" must be a valid object`; } @@ -352,9 +376,15 @@ export interface ParseGraphQLClassConfig { update: ?boolean, // delete is a reserved key word in js destroy: ?boolean, + createMany: ?boolean, + updateMany: ?boolean, + deleteMany: ?boolean, createAlias: ?String, updateAlias: ?String, destroyAlias: ?String, + createManyAlias: ?String, + updateManyAlias: ?String, + deleteManyAlias: ?String, }; } diff --git a/src/Error.js b/src/Error.js index 55bbd6ebea..3ed96a2140 100644 --- a/src/Error.js +++ b/src/Error.js @@ -1,4 +1,5 @@ import defaultLogger from './logger'; +import Utils from './Utils'; /** * Creates a sanitized error that hides detailed information from clients @@ -43,4 +44,49 @@ function createSanitizedHttpError(statusCode, detailedMessage, config) { return error; } -export { createSanitizedError, createSanitizedHttpError }; +function safeBulkReasonDetailedMessage(reason) { + if (reason === undefined || reason === null) { + return 'Internal server error'; + } + try { + let detail; + if (typeof reason.message === 'string') { + detail = reason.message; + } else { + detail = String(reason); + } + return typeof detail === 'string' ? detail : 'Internal server error'; + } catch { + return 'Internal server error'; + } +} + +/** + * `{ code, message }` for GraphQL bulk mutation per-item failures (`ParseGraphQLBulkError`). + * `Parse.Error` uses `createSanitizedError`; other values are logged and mapped to a generic message when sanitizing. + * + * @param {unknown} reason + * @param {object} config + * @returns {{ code: number, message: string }} + */ +function bulkErrorPayloadFromReason(reason, config) { + if (reason instanceof Parse.Error) { + const sanitized = createSanitizedError(reason.code, reason.message, config); + return { code: sanitized.code, message: sanitized.message }; + } + const detailedMessage = safeBulkReasonDetailedMessage(reason); + if (process.env.TESTING) { + defaultLogger.error('Bulk mutation non-Parse error:', detailedMessage); + } else { + defaultLogger.error( + 'Bulk mutation non-Parse error:', + detailedMessage, + Utils.isNativeError(reason) ? reason.stack : '' + ); + } + const message = + config?.enableSanitizedErrorResponse !== false ? 'Internal server error' : detailedMessage; + return { code: Parse.Error.INTERNAL_SERVER_ERROR, message }; +} + +export { createSanitizedError, createSanitizedHttpError, bulkErrorPayloadFromReason }; diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js index d24047329c..82c34a0342 100644 --- a/src/GraphQL/loaders/defaultGraphQLTypes.js +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -1177,6 +1177,22 @@ const POLYGON_WHERE_INPUT = new GraphQLInputObjectType({ }, }); +const PARSE_GRAPHQL_BULK_ERROR = new GraphQLObjectType({ + name: 'ParseGraphQLBulkError', + description: + 'Error for a single entry in a bulk GraphQL mutation (createMany, updateMany, deleteMany).', + fields: { + code: { + description: 'Parse error code.', + type: new GraphQLNonNull(GraphQLInt), + }, + message: { + description: 'Error message.', + type: new GraphQLNonNull(GraphQLString), + }, + }, +}); + const ELEMENT = new GraphQLObjectType({ name: 'Element', description: "The Element object type is used to return array items' value.", @@ -1263,6 +1279,7 @@ const load = parseGraphQLSchema => { parseGraphQLSchema.addGraphQLType(PUBLIC_ACL, true); parseGraphQLSchema.addGraphQLType(SUBQUERY_INPUT, true); parseGraphQLSchema.addGraphQLType(SELECT_INPUT, true); + parseGraphQLSchema.addGraphQLType(PARSE_GRAPHQL_BULK_ERROR, true); }; export { @@ -1321,6 +1338,7 @@ export { CENTER_SPHERE_INPUT, GEO_WITHIN_INPUT, GEO_INTERSECTS_INPUT, + PARSE_GRAPHQL_BULK_ERROR, equalTo, notEqualTo, lessThan, diff --git a/src/GraphQL/loaders/parseClassMutations.js b/src/GraphQL/loaders/parseClassMutations.js index df9a096995..3a2a904266 100644 --- a/src/GraphQL/loaders/parseClassMutations.js +++ b/src/GraphQL/loaders/parseClassMutations.js @@ -1,4 +1,5 @@ -import { GraphQLNonNull } from 'graphql'; +import Parse from 'parse/node'; +import { GraphQLNonNull, GraphQLList, GraphQLBoolean, GraphQLObjectType } from 'graphql'; import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; import getFieldNames from 'graphql-list-fields'; @@ -9,6 +10,37 @@ import * as objectsQueries from '../helpers/objectsQueries'; import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; import { transformClassNameToGraphQL } from '../transformers/className'; import { transformTypes } from '../transformers/mutation'; +import { createSanitizedError, bulkErrorPayloadFromReason } from '../../Error'; +import { getBatchRequestLimit, isBatchRequestLimitExceeded } from '../../batchRequestLimit'; + +const normalizeObjectIdForClass = (id, className) => { + try { + const globalIdObject = fromGlobalId(id); + if (globalIdObject.type === className) { + return globalIdObject.id; + } + } catch { + // `id` is not a Relay global id; use as Parse objectId + } + return id; +}; + +const assertBulkInputLength = (count, config, auth) => { + if (count === 0) { + throw createSanitizedError( + Parse.Error.INVALID_JSON, + 'bulk input must contain at least one item', + config + ); + } + if (isBatchRequestLimitExceeded(count, config, auth)) { + const batchRequestLimit = getBatchRequestLimit(config); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bulk input contains ${count} items, which exceeds the limit of ${batchRequestLimit}.` + ); + } +}; const filterDeletedFields = fields => Object.keys(fields).reduce((acc, key) => { @@ -50,9 +82,26 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG destroyAlias: destroyAlias = '', } = getParseClassMutationConfig(parseClassConfig); + const createManyExplicit = parseClassConfig?.mutation?.createMany; + const updateManyExplicit = parseClassConfig?.mutation?.updateMany; + const deleteManyExplicit = parseClassConfig?.mutation?.deleteMany; + const isCreateManyEnabled = createManyExplicit !== undefined ? createManyExplicit : isCreateEnabled; + const isUpdateManyEnabled = updateManyExplicit !== undefined ? updateManyExplicit : isUpdateEnabled; + const isDeleteManyEnabled = deleteManyExplicit !== undefined ? deleteManyExplicit : isDestroyEnabled; + const allowCreateMany = + isCreateManyEnabled && (isCreateEnabled || createManyExplicit === true); + const allowUpdateMany = + isUpdateManyEnabled && (isUpdateEnabled || updateManyExplicit === true); + const allowDeleteMany = + isDeleteManyEnabled && (isDestroyEnabled || deleteManyExplicit === true); + const createManyAliasCfg = parseClassConfig?.mutation?.createManyAlias || ''; + const updateManyAliasCfg = parseClassConfig?.mutation?.updateManyAlias || ''; + const deleteManyAliasCfg = parseClassConfig?.mutation?.deleteManyAlias || ''; + const { classGraphQLCreateType, classGraphQLUpdateType, + classGraphQLUpdateManyItemType, classGraphQLOutputType, } = parseGraphQLSchema.parseClassTypes[className]; @@ -182,11 +231,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG if (!fields) { fields = {}; } const { config, auth, info } = context; - const globalIdObject = fromGlobalId(id); - - if (globalIdObject.type === className) { - id = globalIdObject.id; - } + id = normalizeObjectIdForClass(id, className); const parseFields = await transformTypes('update', fields, { className, @@ -287,11 +332,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG let { id } = cloneArgs(args); const { config, auth, info } = context; - const globalIdObject = fromGlobalId(id); - - if (globalIdObject.type === className) { - id = globalIdObject.id; - } + id = normalizeObjectIdForClass(id, className); const selectedFields = getFieldNames(mutationInfo) .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) @@ -332,6 +373,432 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG parseGraphQLSchema.addGraphQLMutation(deleteGraphQLMutationName, deleteGraphQLMutation); } } + + if (allowCreateMany) { + const createManyGraphQLMutationName = createManyAliasCfg || `createMany${graphQLClassName}`; + const createManyResultsPrefix = `results.${getGraphQLQueryName}.`; + const createManyResultItemType = new GraphQLObjectType({ + name: `CreateMany${graphQLClassName}ResultItem`, + description: `One result entry for ${createManyGraphQLMutationName}.`, + fields: () => ({ + success: { + description: 'Whether this object was created successfully.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + [getGraphQLQueryName]: { + description: `The created object when success is true.`, + type: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }, + error: { + description: 'Present when success is false.', + type: defaultGraphQLTypes.PARSE_GRAPHQL_BULK_ERROR, + }, + }), + }); + const createManyGraphQLMutation = mutationWithClientMutationId({ + name: `CreateMany${graphQLClassName}`, + description: `The ${createManyGraphQLMutationName} mutation creates multiple objects of the ${graphQLClassName} class. Each entry succeeds or fails independently.`, + inputFields: { + fields: { + description: 'List of field sets; one object will be created per element.', + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(classGraphQLCreateType || defaultGraphQLTypes.OBJECT)) + ), + }, + }, + outputFields: { + results: { + description: 'Creation results in the same order as the input list.', + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(createManyResultItemType)) + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { fields: fieldsList } = cloneArgs(args); + if (!fieldsList) { + fieldsList = []; + } + const { config, auth, info } = context; + assertBulkInputLength(fieldsList.length, config, auth); + + const settled = await Promise.allSettled( + fieldsList.map(fieldSet => + (async () => { + let fields = fieldSet ? cloneArgs({ fields: fieldSet }).fields : {}; + if (!fields) { + fields = {}; + } + const parseFields = await transformTypes('create', fields, { + className, + parseGraphQLSchema, + originalFields: fieldSet, + req: { config, auth, info }, + }); + const createdObject = await objectsMutations.createObject( + className, + parseFields, + config, + auth, + info + ); + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(createManyResultsPrefix)) + .map(field => field.replace(createManyResultsPrefix, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields( + fields, + keys, + include, + ['id', 'objectId', 'createdAt', 'updatedAt'] + ); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys, + parseGraphQLSchema.parseClasses + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + return { + ...createdObject, + updatedAt: createdObject.createdAt, + ...filterDeletedFields(parseFields), + ...optimizedObject, + }; + })() + ) + ); + + const results = settled.map(r => { + if (r.status === 'fulfilled') { + return { + success: true, + [getGraphQLQueryName]: r.value, + error: null, + }; + } + return { + success: false, + [getGraphQLQueryName]: null, + error: bulkErrorPayloadFromReason(r.reason, config), + }; + }); + + return { results }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(createManyResultItemType) && + parseGraphQLSchema.addGraphQLType(createManyGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(createManyGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(createManyGraphQLMutationName, createManyGraphQLMutation); + } + } + + if (allowUpdateMany) { + const updateManyGraphQLMutationName = updateManyAliasCfg || `updateMany${graphQLClassName}`; + const updateManyResultsPrefix = `results.${getGraphQLQueryName}.`; + const updateManyResultItemType = new GraphQLObjectType({ + name: `UpdateMany${graphQLClassName}ResultItem`, + description: `One result entry for ${updateManyGraphQLMutationName}.`, + fields: () => ({ + success: { + description: 'Whether this object was updated successfully.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + [getGraphQLQueryName]: { + description: `The updated object when success is true.`, + type: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }, + error: { + description: 'Present when success is false.', + type: defaultGraphQLTypes.PARSE_GRAPHQL_BULK_ERROR, + }, + }), + }); + const updateManyGraphQLMutation = mutationWithClientMutationId({ + name: `UpdateMany${graphQLClassName}`, + description: `The ${updateManyGraphQLMutationName} mutation updates multiple objects of the ${graphQLClassName} class. Each entry succeeds or fails independently.`, + inputFields: { + updates: { + description: 'List of id + fields pairs; one update per element.', + type: new GraphQLNonNull( + new GraphQLList( + new GraphQLNonNull( + classGraphQLUpdateManyItemType || defaultGraphQLTypes.OBJECT + ) + ) + ), + }, + }, + outputFields: { + results: { + description: 'Update results in the same order as the input list.', + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(updateManyResultItemType)) + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { updates } = cloneArgs(args); + if (!updates) { + updates = []; + } + const { config, auth, info } = context; + assertBulkInputLength(updates.length, config, auth); + + const settled = await Promise.allSettled( + updates.map(updateEntry => + (async () => { + let { id, fields } = updateEntry; + if (!fields) { + fields = {}; + } + id = normalizeObjectIdForClass(id, className); + const parseFields = await transformTypes('update', fields, { + className, + parseGraphQLSchema, + originalFields: updateEntry.fields, + req: { config, auth, info }, + }); + const updatedObject = await objectsMutations.updateObject( + className, + id, + parseFields, + config, + auth, + info + ); + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(updateManyResultsPrefix)) + .map(field => field.replace(updateManyResultsPrefix, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields( + fields, + keys, + include, + ['id', 'objectId', 'updatedAt'] + ); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys, + parseGraphQLSchema.parseClasses + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + return { + objectId: id, + ...updatedObject, + ...filterDeletedFields(parseFields), + ...optimizedObject, + }; + })() + ) + ); + + const results = settled.map(r => { + if (r.status === 'fulfilled') { + return { + success: true, + [getGraphQLQueryName]: r.value, + error: null, + }; + } + return { + success: false, + [getGraphQLQueryName]: null, + error: bulkErrorPayloadFromReason(r.reason, config), + }; + }); + + return { results }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(updateManyResultItemType) && + parseGraphQLSchema.addGraphQLType(updateManyGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(updateManyGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(updateManyGraphQLMutationName, updateManyGraphQLMutation); + } + } + + if (allowDeleteMany) { + const deleteManyGraphQLMutationName = deleteManyAliasCfg || `deleteMany${graphQLClassName}`; + const deleteManyResultsPrefix = `results.${getGraphQLQueryName}.`; + const deleteManyResultItemType = new GraphQLObjectType({ + name: `DeleteMany${graphQLClassName}ResultItem`, + description: `One result entry for ${deleteManyGraphQLMutationName}.`, + fields: () => ({ + success: { + description: 'Whether this object was deleted successfully.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + [getGraphQLQueryName]: { + description: `The object before deletion when success is true.`, + type: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }, + error: { + description: 'Present when success is false.', + type: defaultGraphQLTypes.PARSE_GRAPHQL_BULK_ERROR, + }, + }), + }); + const deleteManyGraphQLMutation = mutationWithClientMutationId({ + name: `DeleteMany${graphQLClassName}`, + description: `The ${deleteManyGraphQLMutationName} mutation deletes multiple objects of the ${graphQLClassName} class. Each entry succeeds or fails independently.`, + inputFields: { + ids: { + description: 'Object ids to delete (global or object id).', + type: new GraphQLNonNull(new GraphQLList(defaultGraphQLTypes.OBJECT_ID)), + }, + }, + outputFields: { + results: { + description: 'Deletion results in the same order as the input list.', + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(deleteManyResultItemType)) + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { ids } = cloneArgs(args); + if (!ids) { + ids = []; + } + const { config, auth, info } = context; + assertBulkInputLength(ids.length, config, auth); + + const settled = await Promise.allSettled( + ids.map(id => + (async () => { + const objectId = normalizeObjectIdForClass(id, className); + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(deleteManyResultsPrefix)) + .map(field => field.replace(deleteManyResultsPrefix, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + let optimizedObject = {}; + if ( + keys && + keys.split(',').filter(key => !['id', 'objectId'].includes(key)).length > 0 + ) { + optimizedObject = await objectsQueries.getObject( + className, + objectId, + keys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + await objectsMutations.deleteObject(className, objectId, config, auth, info); + return { + className, + objectId, + ...optimizedObject, + }; + })() + ) + ); + + const results = settled.map(r => { + if (r.status === 'fulfilled') { + return { + success: true, + [getGraphQLQueryName]: r.value, + error: null, + }; + } + return { + success: false, + [getGraphQLQueryName]: null, + error: bulkErrorPayloadFromReason(r.reason, config), + }; + }); + + return { results }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(deleteManyResultItemType) && + parseGraphQLSchema.addGraphQLType(deleteManyGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(deleteManyGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(deleteManyGraphQLMutationName, deleteManyGraphQLMutation); + } + } }; export { load }; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index c6c08c8889..f7c980adb0 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -186,6 +186,20 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla }); classGraphQLUpdateType = parseGraphQLSchema.addGraphQLType(classGraphQLUpdateType); + const classGraphQLUpdateManyItemTypeName = `UpdateMany${graphQLClassName}ItemInput`; + let classGraphQLUpdateManyItemType = new GraphQLInputObjectType({ + name: classGraphQLUpdateManyItemTypeName, + description: `The ${classGraphQLUpdateManyItemTypeName} type is used for each entry in updateMany on the ${graphQLClassName} class.`, + fields: () => ({ + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + fields: { + description: 'These are the fields that will be used to update the object.', + type: new GraphQLNonNull(classGraphQLUpdateType || defaultGraphQLTypes.OBJECT), + }, + }), + }); + classGraphQLUpdateManyItemType = parseGraphQLSchema.addGraphQLType(classGraphQLUpdateManyItemType); + const classGraphQLPointerTypeName = `${graphQLClassName}PointerInput`; let classGraphQLPointerType = new GraphQLInputObjectType({ name: classGraphQLPointerTypeName, @@ -505,6 +519,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla classGraphQLRelationType, classGraphQLCreateType, classGraphQLUpdateType, + classGraphQLUpdateManyItemType, classGraphQLConstraintsType, classGraphQLRelationConstraintsType, classGraphQLFindArgs, diff --git a/src/batch.js b/src/batch.js index c3c1b2751d..e154ea251d 100644 --- a/src/batch.js +++ b/src/batch.js @@ -1,5 +1,6 @@ const Parse = require('parse/node').Parse; const path = require('path'); +const { isBatchRequestLimitExceeded, getBatchRequestLimit } = require('./batchRequestLimit'); // These methods handle batch requests. const batchPath = '/batch'; @@ -67,8 +68,8 @@ async function handleBatch(router, req) { if (!Array.isArray(req.body?.requests)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); } - const batchRequestLimit = req.config?.requestComplexity?.batchRequestLimit ?? -1; - if (batchRequestLimit > -1 && !req.auth?.isMaster && !req.auth?.isMaintenance && req.body.requests.length > batchRequestLimit) { + if (isBatchRequestLimitExceeded(req.body.requests.length, req.config, req.auth)) { + const batchRequestLimit = getBatchRequestLimit(req.config); throw new Parse.Error( Parse.Error.INVALID_JSON, `Batch request contains ${req.body.requests.length} sub-requests, which exceeds the limit of ${batchRequestLimit}.` diff --git a/src/batchRequestLimit.js b/src/batchRequestLimit.js new file mode 100644 index 0000000000..ebfc3f7e4c --- /dev/null +++ b/src/batchRequestLimit.js @@ -0,0 +1,19 @@ +/** + * Shared logic for `requestComplexity.batchRequestLimit` (REST batch, GraphQL bulk mutations, etc.). + * Matches `src/batch.js` behavior: master and maintenance keys bypass the limit. + */ +function getBatchRequestLimit(config) { + return config?.requestComplexity?.batchRequestLimit ?? -1; +} + +function isBatchRequestLimitExceeded(count, config, auth) { + const batchRequestLimit = getBatchRequestLimit(config); + return ( + batchRequestLimit > -1 && + !auth?.isMaster && + !auth?.isMaintenance && + count > batchRequestLimit + ); +} + +export { getBatchRequestLimit, isBatchRequestLimitExceeded };