From 5cf25d9c2e72a06f3e9e07c0bda9d7a0a82b7622 Mon Sep 17 00:00:00 2001 From: Max Schridde Date: Wed, 25 Oct 2023 17:01:47 -0700 Subject: [PATCH 1/3] ra-data-graphql-simple Data Provider Extension Interface + Realtime Extension --- packages/ra-data-graphql-simple/README.md | 57 ++++++ .../src/extensions/index.ts | 19 ++ .../src/extensions/realtime.test.ts | 170 ++++++++++++++++++ .../src/extensions/realtime.ts | 125 +++++++++++++ .../ra-data-graphql-simple/src/index.test.ts | 14 ++ packages/ra-data-graphql-simple/src/index.ts | 132 ++++++++------ packages/ra-data-graphql/src/index.test.ts | 50 ++++-- packages/ra-data-graphql/src/index.ts | 4 +- 8 files changed, 501 insertions(+), 70 deletions(-) create mode 100644 packages/ra-data-graphql-simple/src/extensions/index.ts create mode 100644 packages/ra-data-graphql-simple/src/extensions/realtime.test.ts create mode 100644 packages/ra-data-graphql-simple/src/extensions/realtime.ts create mode 100644 packages/ra-data-graphql-simple/src/index.test.ts diff --git a/packages/ra-data-graphql-simple/README.md b/packages/ra-data-graphql-simple/README.md index 0b40a0ae2d8..ccdc2d291f8 100644 --- a/packages/ra-data-graphql-simple/README.md +++ b/packages/ra-data-graphql-simple/README.md @@ -213,6 +213,63 @@ buildApolloProvider({ introspection: introspectionOptions }); 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. +## Data Provider Extensions + +Your GraphQL backend may support functionality that extends beyond the default Data Provider methods. One such example of this would be implementing GraphQL subscriptions and integrating [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime) to power realtime updates in your React-Admin application. The extensions pattern allows you to easily expand the Data Provider methods to power this additional functionality. A Data Provider Extention is defined by the following type: + +```js +type DataProviderExtension = { + methodFactory: ( + dataProvider: DataProvider, + ...args: any[] + ) => { [k: string]: DataProviderMethod }; + factoryArgs?: any[]; + introspectionOperationNames?: IntrospectionOptions['operationNames']; +} +``` + +The `methodFactory` is a required function attribute that generates the additional Data Provider methods. It always receives the dataProvider as it's first argument. Arguments defined in the factoryArgs optional attribute will also be passed into `methodFactory`. `introspectionOperationNames` is an optional object attribute that allows you to inform React-Admin hooks and UI components of how these methods map to the GraphQL schema. + +### Realtime Extension + +`ra-data-graphql-simple` comes with a Realtime Data Provider Extension out of the box. If your app uses [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime), you can drop in the Realtime Extension and light up realtime events in no time. Here is an example integration: + +```js +// in App.js +import React from 'react'; +import { Component } from 'react'; +import buildGraphQLProvider, { defaultOptions, DataProviderExtensions } from 'ra-data-graphql-simple'; +import { Admin, Resource } from 'react-admin'; + +import { PostCreate, PostEdit, PostList } from './posts'; + +const dPOptions = { + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [DataProviderExtensions.Realtime] +} + +const App = () => { + + const [dataProvider, setDataProvider] = React.useState(null); + React.useEffect(() => { + buildGraphQLProvider(dPOptions) + .then(graphQlDataProvider => setDataProvider(() => graphQlDataProvider)); + }, []); + + if (!dataProvider) { + return
Loading < /div>; + } + + return ( + + + + ); +} + +export default App; +``` + ## Contributing Run the tests with this command: diff --git a/packages/ra-data-graphql-simple/src/extensions/index.ts b/packages/ra-data-graphql-simple/src/extensions/index.ts new file mode 100644 index 00000000000..7bbb6df8203 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/index.ts @@ -0,0 +1,19 @@ +import { DataProvider } from 'ra-core'; +import { IntrospectionOptions } from 'ra-data-graphql'; + +import { RealtimeExtension } from './realtime'; + +type DataProviderMethod = (...args: any[]) => Promise<{ data: any }>; + +export type DataProviderExtension = { + methodFactory: ( + dataProvider: DataProvider, + ...args: any[] + ) => { [k: string]: DataProviderMethod }; + factoryArgs?: any[]; + introspectionOperationNames?: IntrospectionOptions['operationNames']; +}; + +export class DataProviderExtensions { + static Realtime = RealtimeExtension; +} diff --git a/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts b/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts new file mode 100644 index 00000000000..85726ab74d9 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/realtime.test.ts @@ -0,0 +1,170 @@ +import { print } from 'graphql'; + +import buildDataProvider from '../'; +import { DataProviderExtensions } from './index'; +import { topicToGQLSubscribe, SUBSCRIBE_LIST, SUBSCRIBE_ONE } from './realtime'; + +describe('Realtime Data Provider Extension', () => { + const resource: any = { name: 'Command' }; + + it('correctly defines methodFactory', async () => { + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + }); + expect( + DataProviderExtensions.Realtime.methodFactory(dataProvider) + .subscribe + ).toBeDefined(); + expect( + DataProviderExtensions.Realtime.methodFactory(dataProvider) + .unsubscribe + ).toBeDefined(); + }); + + it('correctly defines introspectionOperationNames', async () => { + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_LIST + ] + ).toBeDefined(); + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_ONE + ] + ).toBeDefined(); + + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_LIST + ](resource) + ).toBe('allCommands'); + expect( + DataProviderExtensions.Realtime.introspectionOperationNames?.[ + SUBSCRIBE_ONE + ](resource) + ).toBe('Command'); + }); + + describe('topicToGQLSubscribe', () => { + it('correctly converts a subscribe list topic to a GQL subscribe query', async () => { + const { query, variables, queryName } = topicToGQLSubscribe( + 'resource/Command' + ); + expect(variables).toEqual({}); + expect(queryName).toBe('allCommands'); + expect(print(query)).toEqual( + `subscription allCommands { + allCommands { + topic + event + } +} +` + ); + }); + + it('correctly converts a subscribe one topic to a GQL subscribe query', async () => { + const { query, variables, queryName } = topicToGQLSubscribe( + 'resource/Command/1' + ); + expect(variables).toEqual({ id: '1' }); + expect(queryName).toBe('Command'); + expect(print(query)).toEqual( + `subscription Command($id: ID!) { + Command(id: $id) { + topic + event + } +} +` + ); + }); + }); + + describe('subscription management', () => { + it('correctly subscribes to multiple topics', async () => { + const subscriptionStore: any[] = []; + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [ + { + ...DataProviderExtensions.Realtime, + factoryArgs: [subscriptionStore], + }, + ], + }); + + const topic1 = 'resource/Command'; + const callback1 = () => {}; + const topic2 = 'resource/Command/1'; + const callback2 = () => {}; + + await dataProvider.subscribe(topic1, callback1); + await dataProvider.subscribe(topic2, callback2); + + expect(subscriptionStore).toHaveLength(2); + + expect(subscriptionStore[0].topic).toBe(topic1); + expect(subscriptionStore[0].subscription).toBeDefined(); + expect(subscriptionStore[0].subscriptionCallback).toBe(callback1); + + expect(subscriptionStore[1].topic).toBe(topic2); + expect(subscriptionStore[1].subscription).toBeDefined(); + expect(subscriptionStore[1].subscriptionCallback).toBe(callback2); + }); + + it('correctly unsubscribes from a topic', async () => { + const subscriptionStore: any[] = []; + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [ + { + ...DataProviderExtensions.Realtime, + factoryArgs: [subscriptionStore], + }, + ], + }); + + const callback1 = () => {}; + const callback2 = () => {}; + const callback3 = () => {}; + const callback4 = () => {}; + + const subscriptions = [ + ['resource/Command', callback1], + ['resource/Command/1', callback2], + ['resource/Command/1', callback3], + ['resource/Command/2', callback4], + ]; + + subscriptions.forEach( + async s => await dataProvider.subscribe(...s) + ); + + expect(subscriptionStore).toHaveLength(4); + + while (subscriptions.length) { + const randomI = Math.floor( + Math.random() * subscriptions.length + ); + const subscription = subscriptions.splice(randomI, 1)[0]; + + await dataProvider.unsubscribe(...subscription); + + expect(subscriptionStore).toHaveLength(subscriptions.length); + + subscriptions.forEach(([topic, callback]) => { + const subscription = subscriptionStore.find( + s => + s.topic === topic && + s.subscriptionCallback === callback + ); + + expect(subscription).toBeDefined(); + expect(subscription.topic).toBe(topic); + expect(subscription.subscriptionCallback).toBe(callback); + }); + } + }); + }); +}); diff --git a/packages/ra-data-graphql-simple/src/extensions/realtime.ts b/packages/ra-data-graphql-simple/src/extensions/realtime.ts new file mode 100644 index 00000000000..21bd72c82c8 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/extensions/realtime.ts @@ -0,0 +1,125 @@ +import { OperationVariables, SubscriptionOptions, gql } from '@apollo/client'; + +import pluralize from 'pluralize'; + +import { DataProviderExtension } from '.'; +import { DataProvider } from 'ra-core'; + +// Below is based off @react-admin/ra-realtime expectations + +export const SUBSCRIBE_LIST = 'SUBSCRIBE_LIST'; +export const SUBSCRIBE_ONE = 'SUBSCRIBE_ONE'; + +const resourceToSubscribeList = (resource: string) => + `all${pluralize(resource)}`; +const resourceToSubscribeOne = (resource: string) => resource; + +const introspectionOperationNames = { + [SUBSCRIBE_LIST]: resource => resourceToSubscribeList(resource.name), + [SUBSCRIBE_ONE]: resource => resourceToSubscribeOne(resource.name), +}; + +export const topicToGQLSubscribe = ( + topic: string +): SubscriptionOptions & { queryName: string } => { + // Two possible topic patterns (from react admin) + // 1. resource/${resource} + // 2. resource/${resource}/${id} + + let raCRUDTopic = topic.startsWith('resource/') ? topic.split('/') : null; + + // TODO handle non crud topics + if (!raCRUDTopic) return { query: gql``, queryName: '', variables: {} }; + + let query; + let variables = {}; + let queryName; + const resource = raCRUDTopic[1]; + + if (raCRUDTopic.length === 2) { + // list subscription + + queryName = resourceToSubscribeList(resource); + + query = gql` + subscription ${queryName} { + ${queryName}{ + topic + event + } + } + `; + } else { + // single resource subscription + queryName = resourceToSubscribeOne(resource); + + query = gql` + subscription ${queryName}($id: ID!) { + ${queryName}(id: $id){ + topic + event + } + } + `; + + variables = { id: raCRUDTopic[2] }; + } + + return { + query, + variables, + queryName, + }; +}; + +type Subscription = { + topic: string; + subscription: any; + subscriptionCallback: any; +}; + +const methodFactory = ( + dataProvider: DataProvider, + subscriptionStore: Subscription[] = [] +) => { + return { + subscribe: async (topic: string, subscriptionCallback: any) => { + const { queryName, ...subscribeOptions } = topicToGQLSubscribe( + topic + ); + const subscription = dataProvider.client + .subscribe(subscribeOptions) + .subscribe(data => + subscriptionCallback(data.data[queryName].event) + ); + + subscriptionStore.push({ + topic, + subscription, + subscriptionCallback, + }); + return Promise.resolve({ data: null }); + }, + + unsubscribe: async (topic: string, subscriptionCallback: any) => { + const indexOfSubscription = subscriptionStore.findIndex( + s => + s.topic === topic && + s.subscriptionCallback === subscriptionCallback + ); + const { subscription } = subscriptionStore.splice( + indexOfSubscription, + 1 + )[0]; + + if (subscription) subscription.unsubscribe(); + + return Promise.resolve({ data: null }); + }, + }; +}; + +export const RealtimeExtension: DataProviderExtension = { + methodFactory, + introspectionOperationNames, +}; diff --git a/packages/ra-data-graphql-simple/src/index.test.ts b/packages/ra-data-graphql-simple/src/index.test.ts new file mode 100644 index 00000000000..d0843388780 --- /dev/null +++ b/packages/ra-data-graphql-simple/src/index.test.ts @@ -0,0 +1,14 @@ +import buildDataProvider from './index'; +import { DataProviderExtensions } from './index'; + +describe('GraphQL data provider', () => { + it('supports Data Provider Extensions', async () => { + const dataProvider = await buildDataProvider({ + clientOptions: { uri: 'http://localhost:4000' }, + extensions: [DataProviderExtensions.Realtime], + }); + + expect(dataProvider.subscribe).toBeDefined(); + expect(dataProvider.unsubscribe).toBeDefined(); + }); +}); diff --git a/packages/ra-data-graphql-simple/src/index.ts b/packages/ra-data-graphql-simple/src/index.ts index 237c9c41927..084b269c96c 100644 --- a/packages/ra-data-graphql-simple/src/index.ts +++ b/packages/ra-data-graphql-simple/src/index.ts @@ -1,8 +1,13 @@ import merge from 'lodash/merge'; -import buildDataProvider, { BuildQueryFactory, Options } from 'ra-data-graphql'; +import buildDataProvider, { + BuildQueryFactory, + Options, + defaultOptions as raDataGraphqlDefaultOptions, +} from 'ra-data-graphql'; import { DataProvider, Identifier } from 'ra-core'; import defaultBuildQuery from './buildQuery'; +import { DataProviderExtension, DataProviderExtensions } from './extensions'; export const buildQuery = defaultBuildQuery; export { buildQueryFactory } from './buildQuery'; @@ -11,60 +16,87 @@ export { default as buildVariables } from './buildVariables'; export { default as getResponseParser } from './getResponseParser'; const defaultOptions = { + ...raDataGraphqlDefaultOptions, buildQuery: defaultBuildQuery, + extensions: [], }; +export { defaultOptions, DataProviderExtensions }; + export default ( - options: Omit & { buildQuery?: BuildQueryFactory } + options: Omit & { + buildQuery?: BuildQueryFactory; + extensions?: DataProviderExtension[]; + } ): 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 { extensions = [], ...customOptions } = options; + const dPOptions = merge({}, defaultOptions, customOptions); + + extensions + .filter( + ({ introspectionOperationNames }) => !!introspectionOperationNames + ) + .forEach(({ introspectionOperationNames }) => { + if (dPOptions.introspection?.operationNames) + dPOptions.introspection.operationNames = merge( + dPOptions.introspection.operationNames, + introspectionOperationNames + ); + }); + + return buildDataProvider(dPOptions).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], + [] + ); - 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], - [] - ); + 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], + [] + ); - return { data }; - }); - }, - }; - } - ); + return { data }; + }); + }, + ...extensions.reduce( + (acc, { methodFactory, factoryArgs = [] }) => ({ + ...acc, + ...methodFactory(...[defaultDataProvider, ...factoryArgs]), + }), + {} + ), + }; + }); }; diff --git a/packages/ra-data-graphql/src/index.test.ts b/packages/ra-data-graphql/src/index.test.ts index 9d7d0b72a97..fb28a777e68 100644 --- a/packages/ra-data-graphql/src/index.test.ts +++ b/packages/ra-data-graphql/src/index.test.ts @@ -5,28 +5,29 @@ import gql from 'graphql-tag'; import buildDataProvider, { BuildQueryFactory } from './index'; describe('GraphQL data provider', () => { + const mockClient = { + mutate: async () => { + throw new ApolloError({ + graphQLErrors: [new GraphQLError('some error')], + }); + }, + }; + const mockBuildQueryFactory = () => { + return () => ({ + query: gql` + mutation { + updateMyResource { + result + } + } + `, + parseResponse: () => ({}), + }); + }; + describe('mutate', () => { describe('with error', () => { it('sets ApolloError in body', async () => { - const mockClient = { - mutate: async () => { - throw new ApolloError({ - graphQLErrors: [new GraphQLError('some error')], - }); - }, - }; - const mockBuildQueryFactory = () => { - return () => ({ - query: gql` - mutation { - updateMyResource { - result - } - } - `, - parseResponse: () => ({}), - }); - }; const dataProvider = await buildDataProvider({ client: (mockClient as unknown) as ApolloClient, introspection: false, @@ -48,4 +49,15 @@ describe('GraphQL data provider', () => { }); }); }); + + it('makes client available', async () => { + const dataProvider = await buildDataProvider({ + client: (mockClient as unknown) as ApolloClient, + introspection: false, + buildQuery: (mockBuildQueryFactory as unknown) as BuildQueryFactory, + }); + + expect(dataProvider.client).toBeDefined(); + expect(dataProvider.client.mutate).toBeDefined(); + }); }); diff --git a/packages/ra-data-graphql/src/index.ts b/packages/ra-data-graphql/src/index.ts index 8f86eb748a1..17e8f6d7ad6 100644 --- a/packages/ra-data-graphql/src/index.ts +++ b/packages/ra-data-graphql/src/index.ts @@ -54,7 +54,7 @@ const RaFetchMethodMap = { update: UPDATE, updateMany: UPDATE_MANY, }; -const defaultOptions = { +export const defaultOptions = { resolveIntrospection: introspectSchema, introspection: { operationNames: { @@ -153,6 +153,8 @@ export default async (options: Options): Promise => { if (typeof name === 'symbol' || name === 'then') { return; } + if (name == 'client') return client; // make client accessible to the proxy + const raFetchMethod = RaFetchMethodMap[name]; return async (resource, params) => { if (introspection) { From 3f5df25857336d479b9d63616ba46c29eb9c0980 Mon Sep 17 00:00:00 2001 From: Max Schridde Date: Tue, 5 Dec 2023 11:53:14 -0800 Subject: [PATCH 2/3] resolve conflicts, code cleanup, passing tests. --- .../ra-data-graphql-simple/src/extensions/realtime.ts | 5 +++-- packages/ra-data-graphql-simple/src/index.ts | 11 ++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/ra-data-graphql-simple/src/extensions/realtime.ts b/packages/ra-data-graphql-simple/src/extensions/realtime.ts index 21bd72c82c8..e3eaec5c906 100644 --- a/packages/ra-data-graphql-simple/src/extensions/realtime.ts +++ b/packages/ra-data-graphql-simple/src/extensions/realtime.ts @@ -98,7 +98,8 @@ const methodFactory = ( subscription, subscriptionCallback, }); - return Promise.resolve({ data: null }); + + return { data: subscriptionStore }; }, unsubscribe: async (topic: string, subscriptionCallback: any) => { @@ -114,7 +115,7 @@ const methodFactory = ( if (subscription) subscription.unsubscribe(); - return Promise.resolve({ data: null }); + return { data: subscriptionStore }; }, }; }; diff --git a/packages/ra-data-graphql-simple/src/index.ts b/packages/ra-data-graphql-simple/src/index.ts index cd5395b85af..5d648a043e6 100644 --- a/packages/ra-data-graphql-simple/src/index.ts +++ b/packages/ra-data-graphql-simple/src/index.ts @@ -19,7 +19,6 @@ export { default as getResponseParser } from './getResponseParser'; const defaultOptions = { ...baseDefaultOptions, buildQuery: defaultBuildQuery, - extensions: [], }; export { defaultOptions, DataProviderExtensions }; @@ -36,11 +35,9 @@ export default ( extensions?: DataProviderExtension[]; } ): Promise => { - const { bulkActionsEnabled = false, extensions = [], ...dPOptions } = merge( - {}, - defaultOptions, - options - ); + const { bulkActionsEnabled = false, extensions = [], ...rest } = options; + + const dPOptions = merge({}, defaultOptions, rest); if (bulkActionsEnabled && dPOptions.introspection?.operationNames) dPOptions.introspection.operationNames = merge( @@ -112,7 +109,7 @@ export default ( ...extensions.reduce( (acc, { methodFactory, factoryArgs = [] }) => ({ ...acc, - ...methodFactory(...[defaultDataProvider, ...factoryArgs]), + ...methodFactory(defaultDataProvider, ...factoryArgs), }), {} ), From 4606ec9369590f0b7ad9379e816ee51a95c8fc8c Mon Sep 17 00:00:00 2001 From: Max Schridde Date: Tue, 12 Dec 2023 09:27:54 -0800 Subject: [PATCH 3/3] update readme. ensure equality check includes type. --- packages/ra-data-graphql-simple/README.md | 2 +- packages/ra-data-graphql/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-data-graphql-simple/README.md b/packages/ra-data-graphql-simple/README.md index 7c5603cdef0..3871f51ddbb 100644 --- a/packages/ra-data-graphql-simple/README.md +++ b/packages/ra-data-graphql-simple/README.md @@ -255,7 +255,7 @@ Your GraphQL backend may not allow multiple deletions or updates in a single que ## Data Provider Extensions -Your GraphQL backend may support functionality that extends beyond the default Data Provider methods. One such example of this would be implementing GraphQL subscriptions and integrating [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime) to power realtime updates in your React-Admin application. The extensions pattern allows you to easily expand the Data Provider methods to power this additional functionality. A Data Provider Extention is defined by the following type: +Your GraphQL backend may support functionality that extends beyond the default Data Provider methods. One such example of this would be implementing GraphQL subscriptions and integrating [ra-realtime](https://marmelab.com/ra-enterprise/modules/ra-realtime) to power realtime updates in your React-Admin application. The extensions pattern allows you to expand the Data Provider methods to power this additional functionality. A Data Provider Extention is defined by the following type: ```js type DataProviderExtension = { diff --git a/packages/ra-data-graphql/src/index.ts b/packages/ra-data-graphql/src/index.ts index f79c03dbebe..ebab8b4d4ac 100644 --- a/packages/ra-data-graphql/src/index.ts +++ b/packages/ra-data-graphql/src/index.ts @@ -154,7 +154,7 @@ export default async (options: Options): Promise => { if (typeof name === 'symbol' || name === 'then') { return; } - if (name == 'client') return client; // make client accessible to the proxy + if (name === 'client') return client; // make client accessible to the proxy const raFetchMethod = RaFetchMethodMap[name]; return async (resource, params) => {