From b4628e04613ec87914a1868aa76fdda314df1a21 Mon Sep 17 00:00:00 2001 From: Max Schridde Date: Tue, 24 Oct 2023 15:32:01 -0700 Subject: [PATCH] DELETE_MANY and UPDATE_MANY native support --- packages/ra-data-graphql-simple/README.md | 11 +- .../src/buildGqlQuery.test.ts | 88 ++++++++++++ .../src/buildGqlQuery.ts | 30 +++- .../src/buildVariables.test.ts | 44 ++++++ .../src/buildVariables.ts | 17 +++ .../src/getResponseParser.test.ts | 76 +++++++++++ .../src/getResponseParser.ts | 10 +- packages/ra-data-graphql-simple/src/index.ts | 129 +++++++++++------- packages/ra-data-graphql/src/index.ts | 3 +- 9 files changed, 353 insertions(+), 55 deletions(-) diff --git a/packages/ra-data-graphql-simple/README.md b/packages/ra-data-graphql-simple/README.md index 0b40a0ae2d8..86c7b0207af 100644 --- a/packages/ra-data-graphql-simple/README.md +++ b/packages/ra-data-graphql-simple/README.md @@ -82,7 +82,12 @@ type Mutation { views: Int! user_id: ID! ): Post + updatePosts( + ids: [ID!] + data: PostBulkUpdatePayload + ) : { ids: [ID!]} deletePost(id: ID!): Post + deletePosts(ids: [ID!]) : { ids: [ID!]} } type Post { @@ -106,6 +111,10 @@ input PostFilter { user_id: ID } +input PostBulkUpdatePayload { + title: String +} + type ListMetadata { count: Int! } @@ -211,7 +220,7 @@ buildApolloProvider({ introspection: introspectionOptions }); ## `DELETE_MANY` and `UPDATE_MANY` Optimizations -Your GraphQL backend may not allow multiple deletions or updates in a single query. This provider simply makes multiple requests to handle those. This is obviously not ideal but can be alleviated by supplying your own `ApolloClient` which could use the [apollo-link-batch-http](https://www.apollographql.com/docs/link/links/batch-http.html) link if your GraphQL backend support query batching. +Your GraphQL backend may not allow multiple deletions or updates in a single query. This provider defaults to simply making multiple requests to handle those. This is obviously not ideal but can be alleviated by supplying your own `ApolloClient` which could use the [apollo-link-batch-http](https://www.apollographql.com/docs/link/links/batch-http.html) link if your GraphQL backend support query batching. ## Contributing diff --git a/packages/ra-data-graphql-simple/src/buildGqlQuery.test.ts b/packages/ra-data-graphql-simple/src/buildGqlQuery.test.ts index d37fdff5d14..848aeb66f24 100644 --- a/packages/ra-data-graphql-simple/src/buildGqlQuery.test.ts +++ b/packages/ra-data-graphql-simple/src/buildGqlQuery.test.ts @@ -7,6 +7,8 @@ import { UPDATE, CREATE, DELETE, + DELETE_MANY, + UPDATE_MANY, } from 'ra-core'; import buildGqlQuery, { buildApolloArgs, @@ -330,6 +332,49 @@ describe('buildGqlQuery', () => { { name: 'bar' }, ], }; + + const queryTypeDeleteMany = { + name: 'deleteCommands', + args: [ + { + name: 'ids', + type: { + kind: TypeKind.LIST, + ofType: { + kind: TypeKind.NON_NULL, + ofType: { + kind: TypeKind.SCALAR, + name: 'ID', + }, + }, + }, + }, + ], + }; + + const queryTypeUpdateMany = { + name: 'updateCommands', + args: [ + { + name: 'ids', + type: { + kind: TypeKind.LIST, + ofType: { + kind: TypeKind.NON_NULL, + ofType: { + kind: TypeKind.SCALAR, + name: 'ID', + }, + }, + }, + }, + { + name: 'data', + type: { kind: TypeKind.OBJECT, name: 'CommandType' }, + }, + ], + }; + const params = { foo: 'foo_value' }; it('returns the correct query for GET_LIST', () => { @@ -513,6 +558,49 @@ describe('buildGqlQuery', () => { } } } +` + ); + }); + + it('returns the correct query for DELETE_MANY', () => { + expect( + print( + buildGqlQuery(introspectionResults)( + resource, + DELETE_MANY, + queryTypeDeleteMany, + { ids: [1, 2, 3] } + ) + ) + ).toEqual( + `mutation deleteCommands($ids: [ID!]) { + data: deleteCommands(ids: $ids) { + ids + } +} +` + ); + }); + + it('returns the correct query for UPDATE_MANY', () => { + expect( + print( + buildGqlQuery(introspectionResults)( + resource, + UPDATE_MANY, + queryTypeUpdateMany, + { + ids: [1, 2, 3], + data: params, + } + ) + ) + ).toEqual( + `mutation updateCommands($ids: [ID!], $data: CommandType) { + data: updateCommands(ids: $ids, data: $data) { + ids + } +} ` ); }); diff --git a/packages/ra-data-graphql-simple/src/buildGqlQuery.ts b/packages/ra-data-graphql-simple/src/buildGqlQuery.ts index 59190f175b1..eb173e100d6 100644 --- a/packages/ra-data-graphql-simple/src/buildGqlQuery.ts +++ b/packages/ra-data-graphql-simple/src/buildGqlQuery.ts @@ -1,4 +1,11 @@ -import { GET_LIST, GET_MANY, GET_MANY_REFERENCE, DELETE } from 'ra-core'; +import { + GET_LIST, + GET_MANY, + GET_MANY_REFERENCE, + DELETE, + DELETE_MANY, + UPDATE_MANY, +} from 'ra-core'; import { QUERY_TYPES, IntrospectionResult, @@ -84,6 +91,27 @@ export default (introspectionResults: IntrospectionResult) => ( ]); } + if (raFetchMethod === DELETE_MANY || raFetchMethod === UPDATE_MANY) { + return gqlTypes.document([ + gqlTypes.operationDefinition( + 'mutation', + gqlTypes.selectionSet([ + gqlTypes.field( + gqlTypes.name(queryType.name), + gqlTypes.name('data'), + args, + null, + gqlTypes.selectionSet([ + gqlTypes.field(gqlTypes.name('ids')), + ]) + ), + ]), + gqlTypes.name(queryType.name), + apolloArgs + ), + ]); + } + return gqlTypes.document([ gqlTypes.operationDefinition( QUERY_TYPES.includes(raFetchMethod) ? 'query' : 'mutation', diff --git a/packages/ra-data-graphql-simple/src/buildVariables.test.ts b/packages/ra-data-graphql-simple/src/buildVariables.test.ts index 57b9ce3e5ea..dd191ca6d49 100644 --- a/packages/ra-data-graphql-simple/src/buildVariables.test.ts +++ b/packages/ra-data-graphql-simple/src/buildVariables.test.ts @@ -5,6 +5,8 @@ import { CREATE, UPDATE, DELETE, + DELETE_MANY, + UPDATE_MANY, } from 'ra-core'; import buildVariables from './buildVariables'; @@ -172,4 +174,46 @@ describe('buildVariables', () => { }); }); }); + + describe('DELETE_MANY', () => { + it('returns correct variables', () => { + const params = { + ids: ['post1'], + }; + expect( + buildVariables(introspectionResult)( + { type: { name: 'Post', inputFields: [] } }, + DELETE_MANY, + params, + {} + ) + ).toEqual({ + ids: ['post1'], + }); + }); + }); + + describe('UPDATE_MANY', () => { + it('returns correct variables', () => { + const params = { + ids: ['post1', 'post2'], + data: { + title: 'New Title', + }, + }; + expect( + buildVariables(introspectionResult)( + { type: { name: 'Post', inputFields: [] } }, + UPDATE_MANY, + params, + {} + ) + ).toEqual({ + ids: ['post1', 'post2'], + data: { + title: 'New Title', + }, + }); + }); + }); }); diff --git a/packages/ra-data-graphql-simple/src/buildVariables.ts b/packages/ra-data-graphql-simple/src/buildVariables.ts index ef6abaa8ea7..894874cf30a 100644 --- a/packages/ra-data-graphql-simple/src/buildVariables.ts +++ b/packages/ra-data-graphql-simple/src/buildVariables.ts @@ -14,6 +14,8 @@ import { CREATE, UPDATE, DELETE, + DELETE_MANY, + UPDATE_MANY, } from 'ra-core'; import { IntrospectionResult, IntrospectedResource } from 'ra-data-graphql'; @@ -63,6 +65,8 @@ export default (introspectionResults: IntrospectionResult) => ( return { id: preparedParams.id, }; + case DELETE_MANY: + return preparedParams; case CREATE: case UPDATE: { return buildCreateUpdateVariables( @@ -72,6 +76,19 @@ export default (introspectionResults: IntrospectionResult) => ( queryType ); } + case UPDATE_MANY: { + const { ids, data: resourceData } = preparedParams; + const { id, ...data } = buildCreateUpdateVariables( + resource, + raFetchMethod, + { data: resourceData }, + queryType + ); + return { + ids, + data, + }; + } } }; diff --git a/packages/ra-data-graphql-simple/src/getResponseParser.test.ts b/packages/ra-data-graphql-simple/src/getResponseParser.test.ts index 52772e1e45e..c5ddc174780 100644 --- a/packages/ra-data-graphql-simple/src/getResponseParser.test.ts +++ b/packages/ra-data-graphql-simple/src/getResponseParser.test.ts @@ -6,6 +6,8 @@ import { CREATE, UPDATE, DELETE, + DELETE_MANY, + UPDATE_MANY, } from 'ra-core'; import getResponseParser from './getResponseParser'; @@ -394,4 +396,78 @@ describe('getResponseParser', () => { }); }); }); + + it('returns the response expected for DELETE_MANY', () => { + const introspectionResults = { + resources: [ + { + type: { + name: 'User', + fields: [ + { name: 'id', type: { kind: TypeKind.SCALAR } }, + { + name: 'firstName', + type: { kind: TypeKind.SCALAR }, + }, + ], + }, + }, + ], + types: [{ name: 'User' }], + }; + const response = { + data: { + data: { + ids: [1, 2, 3, 4], + }, + }, + }; + + expect( + getResponseParser(introspectionResults)( + DELETE_MANY, + undefined, + undefined + )(response) + ).toEqual({ + data: [1, 2, 3, 4], + }); + }); + + it('returns the response expected for UPDATE_MANY', () => { + const introspectionResults = { + resources: [ + { + type: { + name: 'User', + fields: [ + { name: 'id', type: { kind: TypeKind.SCALAR } }, + { + name: 'firstName', + type: { kind: TypeKind.SCALAR }, + }, + ], + }, + }, + ], + types: [{ name: 'User' }], + }; + const response = { + data: { + data: { + ids: [1, 2, 3, 4], + }, + }, + }; + + expect( + getResponseParser(introspectionResults)( + UPDATE_MANY, + undefined, + undefined + )(response) + ).toEqual({ + data: [1, 2, 3, 4], + }); + }); }); diff --git a/packages/ra-data-graphql-simple/src/getResponseParser.ts b/packages/ra-data-graphql-simple/src/getResponseParser.ts index 0dfca14deee..97a5c64a825 100644 --- a/packages/ra-data-graphql-simple/src/getResponseParser.ts +++ b/packages/ra-data-graphql-simple/src/getResponseParser.ts @@ -1,4 +1,10 @@ -import { GET_LIST, GET_MANY, GET_MANY_REFERENCE } from 'ra-core'; +import { + DELETE_MANY, + GET_LIST, + GET_MANY, + GET_MANY_REFERENCE, + UPDATE_MANY, +} from 'ra-core'; import { IntrospectionResult, IntrospectedResource } from 'ra-data-graphql'; import { IntrospectionField } from 'graphql'; import { ApolloQueryResult } from '@apollo/client'; @@ -19,6 +25,8 @@ export default (_introspectionResults: IntrospectionResult) => ( data: response.data.items.map(sanitizeResource), total: response.data.total.count, }; + } else if (raFetchMethod === DELETE_MANY || raFetchMethod === UPDATE_MANY) { + return { data: sanitizeResource(data.data).ids }; } return { data: sanitizeResource(data.data) }; diff --git a/packages/ra-data-graphql-simple/src/index.ts b/packages/ra-data-graphql-simple/src/index.ts index 237c9c41927..e80bd02d0ca 100644 --- a/packages/ra-data-graphql-simple/src/index.ts +++ b/packages/ra-data-graphql-simple/src/index.ts @@ -1,6 +1,11 @@ import merge from 'lodash/merge'; -import buildDataProvider, { BuildQueryFactory, Options } from 'ra-data-graphql'; -import { DataProvider, Identifier } from 'ra-core'; +import buildDataProvider, { + BuildQueryFactory, + Options, + defaultOptions as baseDefaultOptions, +} from 'ra-data-graphql'; +import { DELETE_MANY, DataProvider, Identifier, UPDATE_MANY } from 'ra-core'; +import pluralize from 'pluralize'; import defaultBuildQuery from './buildQuery'; @@ -11,60 +16,82 @@ export { default as buildVariables } from './buildVariables'; export { default as getResponseParser } from './getResponseParser'; const defaultOptions = { + ...baseDefaultOptions, buildQuery: defaultBuildQuery, }; +const bulkActionOperationNames = { + [DELETE_MANY]: resource => `delete${pluralize(resource.name)}`, + [UPDATE_MANY]: resource => `update${pluralize(resource.name)}`, +}; + export default ( - options: Omit & { buildQuery?: BuildQueryFactory } + options: Omit & { + buildQuery?: BuildQueryFactory; + bulkActionsEnabled?: boolean; + } ): Promise => { - return buildDataProvider(merge({}, defaultOptions, options)).then( - defaultDataProvider => { - return { - ...defaultDataProvider, - // This provider does not support multiple deletions so instead we send multiple DELETE requests - // This can be optimized using the apollo-link-batch-http link - deleteMany: (resource, params) => { - const { ids, ...otherParams } = params; - return Promise.all( - ids.map(id => - defaultDataProvider.delete(resource, { - id, - previousData: null, - ...otherParams, - }) - ) - ).then(results => { - const data = results.reduce( - (acc, { data }) => [...acc, data.id], - [] - ); + const { bulkActionsEnabled = false, ...dPOptions } = merge( + {}, + defaultOptions, + options + ); - return { data }; - }); - }, - // This provider does not support multiple deletions so instead we send multiple UPDATE requests - // This can be optimized using the apollo-link-batch-http link - updateMany: (resource, params) => { - const { ids, data, ...otherParams } = params; - return Promise.all( - ids.map(id => - defaultDataProvider.update(resource, { - id, - data: data, - previousData: null, - ...otherParams, - }) - ) - ).then(results => { - const data = results.reduce( - (acc, { data }) => [...acc, data.id], - [] - ); + if (bulkActionsEnabled && dPOptions.introspection?.operationNames) + dPOptions.introspection.operationNames = merge( + dPOptions.introspection.operationNames, + bulkActionOperationNames + ); - return { data }; - }); - }, - }; - } - ); + return buildDataProvider(dPOptions).then(defaultDataProvider => { + return { + ...defaultDataProvider, + // This provider defaults to sending multiple DELETE requests for DELETE_MANY + // and multiple UPDATE requests for UPDATE_MANY unless bulk actions are enabled + // This can be optimized using the apollo-link-batch-http link + ...(bulkActionsEnabled + ? {} + : { + deleteMany: (resource, params) => { + const { ids, ...otherParams } = params; + return Promise.all( + ids.map(id => + defaultDataProvider.delete(resource, { + id, + previousData: null, + ...otherParams, + }) + ) + ).then(results => { + const data = results.reduce( + (acc, { data }) => [...acc, data.id], + [] + ); + + return { data }; + }); + }, + updateMany: (resource, params) => { + const { ids, data, ...otherParams } = params; + return Promise.all( + ids.map(id => + defaultDataProvider.update(resource, { + id, + data: data, + previousData: null, + ...otherParams, + }) + ) + ).then(results => { + const data = results.reduce( + (acc, { data }) => [...acc, data.id], + [] + ); + + return { data }; + }); + }, + }), + }; + }); }; diff --git a/packages/ra-data-graphql/src/index.ts b/packages/ra-data-graphql/src/index.ts index 8f86eb748a1..c83676c2182 100644 --- a/packages/ra-data-graphql/src/index.ts +++ b/packages/ra-data-graphql/src/index.ts @@ -54,7 +54,8 @@ const RaFetchMethodMap = { update: UPDATE, updateMany: UPDATE_MANY, }; -const defaultOptions = { + +export const defaultOptions = { resolveIntrospection: introspectSchema, introspection: { operationNames: {