diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc129f35f2..37402de71a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Expect active development and potentially significant breaking changes in the `0.x` track. We'll try to be diligent about releasing a `1.0` version in a timely fashion (ideally within 3 to 6 months), to signal the start of a more stable API. ### vNEXT +- Add direct cache manipulation read and write methods to provide the user the power to interact with Apollo’s GraphQL data representation outside of mutations. [PR #1310](https://github.com/apollographql/apollo-client/pull/1310) - Clear pollInterval in `ObservableQuery#stopPolling` so that resubscriptions don't start polling again [PR #1328](https://github.com/apollographql/apollo-client/pull/1328) - Update dependencies (Typescript 2.2.1, node typings, etc.) [PR #1332][https://github.com/apollographql/apollo-client/pull/1332] - Fix bug that caused: `error: Cannot read property 'data' of undefined`, when no previous result was available [PR #1339][https://github.com/apollographql/apollo-client/pull/1339]. diff --git a/src/ApolloClient.ts b/src/ApolloClient.ts index fd0abd90e24..f31924eb38b 100644 --- a/src/ApolloClient.ts +++ b/src/ApolloClient.ts @@ -11,6 +11,8 @@ import { SelectionSetNode, /* tslint:enable */ + DocumentNode, + FragmentDefinitionNode, } from 'graphql'; import { @@ -58,6 +60,16 @@ import { storeKeyNameFromFieldNameAndArgs, } from './data/storeUtils'; +import { + getFragmentQueryDocument, +} from './queries/getFromAST'; + +import { + DataProxy, + ReduxDataProxy, + TransactionDataProxy, +} from './data/proxy'; + import { version, } from './version'; @@ -100,6 +112,7 @@ export default class ApolloClient { public queryDeduplication: boolean; private devToolsHookCb: Function; + private proxy: DataProxy | undefined; /** * Constructs an instance of {@link ApolloClient}. @@ -336,6 +349,114 @@ export default class ApolloClient { return this.queryManager.startGraphQLSubscription(realOptions); } + /** + * Tries to read some data from the store in the shape of the provided + * GraphQL query without making a network request. This method will start at + * the root query. To start at a specific id returned by `dataIdFromObject` + * use `readFragment`. + * + * @param query The GraphQL query shape to be used. + * + * @param variables Any variables that the GraphQL query may depend on. + */ + public readQuery(config: { + query: DocumentNode, + variables?: Object, + }): QueryType { + return this.initProxy().readQuery(config); + } + + /** + * Tries to read some data from the store in the shape of the provided + * GraphQL fragment without making a network request. This method will read a + * GraphQL fragment from any arbitrary id that is currently cached, unlike + * `readQuery` which will only read from the root query. + * + * You must pass in a GraphQL document with a single fragment or a document + * with multiple fragments that represent what you are reading. If you pass + * in a document with multiple fragments then you must also specify a + * `fragmentName`. + * + * @param id The root id to be used. This id should take the same form as the + * value returned by your `dataIdFromObject` function. If a value with your + * id does not exist in the store, `null` will be returned. + * + * @param fragment A GraphQL document with one or more fragments the shape of + * which will be used. If you provide more then one fragments then you must + * also specify the next argument, `fragmentName`, to select a single + * fragment to use when reading. + * + * @param fragmentName The name of the fragment in your GraphQL document to + * be used. Pass `undefined` if there is only one fragment and you want to + * use that. + * + * @param variables Any variables that your GraphQL fragments depend on. + */ + public readFragment(config: { + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): FragmentType | null { + return this.initProxy().readFragment(config); + } + + /** + * Writes some data in the shape of the provided GraphQL query directly to + * the store. This method will start at the root query. To start at a a + * specific id returned by `dataIdFromObject` then use `writeFragment`. + * + * @param data The data you will be writing to the store. + * + * @param query The GraphQL query shape to be used. + * + * @param variables Any variables that the GraphQL query may depend on. + */ + public writeQuery(config: { + data: any, + query: DocumentNode, + variables?: Object, + }): void { + return this.initProxy().writeQuery(config); + } + + /** + * Writes some data in the shape of the provided GraphQL fragment directly to + * the store. This method will write to a GraphQL fragment from any arbitrary + * id that is currently cached, unlike `writeQuery` which will only write + * from the root query. + * + * You must pass in a GraphQL document with a single fragment or a document + * with multiple fragments that represent what you are writing. If you pass + * in a document with multiple fragments then you must also specify a + * `fragmentName`. + * + * @param data The data you will be writing to the store. + * + * @param id The root id to be used. This id should take the same form as the + * value returned by your `dataIdFromObject` function. + * + * @param fragment A GraphQL document with one or more fragments the shape of + * which will be used. If you provide more then one fragments then you must + * also specify the next argument, `fragmentName`, to select a single + * fragment to use when reading. + * + * @param fragmentName The name of the fragment in your GraphQL document to + * be used. Pass `undefined` if there is only one fragment and you want to + * use that. + * + * @param variables Any variables that your GraphQL fragments depend on. + */ + public writeFragment(config: { + data: any, + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): void { + return this.initProxy().writeFragment(config); + } + /** * Returns a reducer function configured according to the `reducerConfig` instance variable. */ @@ -457,4 +578,20 @@ export default class ApolloClient { queryDeduplication: this.queryDeduplication, }); }; + + /** + * Initializes a data proxy for this client instance if one does not already + * exist and returns either a previously initialized proxy instance or the + * newly initialized instance. + */ + private initProxy(): DataProxy { + if (!this.proxy) { + this.initStore(); + this.proxy = new ReduxDataProxy( + this.store, + this.reduxRootSelector || defaultReduxRootSelector, + ); + } + return this.proxy; + } } diff --git a/src/actions.ts b/src/actions.ts index 0f2e4fb614d..09e09ff3ad8 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -7,6 +7,10 @@ import { MutationQueryReducer, } from './data/mutationResults'; +import { + DataProxy, +} from './data/proxy'; + import { ApolloReducer, } from './store'; @@ -89,6 +93,7 @@ export interface MutationInitAction { optimisticResponse: Object | undefined; extraReducers?: ApolloReducer[]; updateQueries?: { [queryId: string]: MutationQueryReducer }; + update?: (proxy: DataProxy, mutationResult: Object) => void; } export function isMutationInitAction(action: ApolloAction): action is MutationInitAction { @@ -105,6 +110,7 @@ export interface MutationResultAction { mutationId: string; extraReducers?: ApolloReducer[]; updateQueries?: { [queryId: string]: MutationQueryReducer }; + update?: (proxy: DataProxy, mutationResult: Object) => void; } export function isMutationResultAction(action: ApolloAction): action is MutationResultAction { @@ -141,7 +147,7 @@ export function isStoreResetAction(action: ApolloAction): action is StoreResetAc return action.type === 'APOLLO_STORE_RESET'; } -export type SubscriptionResultAction = { +export interface SubscriptionResultAction { type: 'APOLLO_SUBSCRIPTION_RESULT'; result: ExecutionResult; subscriptionId: number; @@ -149,12 +155,28 @@ export type SubscriptionResultAction = { document: DocumentNode; operationName: string; extraReducers?: ApolloReducer[]; -}; +} export function isSubscriptionResultAction(action: ApolloAction): action is SubscriptionResultAction { return action.type === 'APOLLO_SUBSCRIPTION_RESULT'; } +export interface DataWrite { + rootId: string; + result: any; + document: DocumentNode; + variables: Object; +} + +export interface WriteAction { + type: 'APOLLO_WRITE'; + writes: Array; +} + +export function isWriteAction(action: ApolloAction): action is WriteAction { + return action.type === 'APOLLO_WRITE'; +} + export type ApolloAction = QueryResultAction | QueryErrorAction | @@ -166,4 +188,5 @@ export type ApolloAction = MutationErrorAction | UpdateQueryResultAction | StoreResetAction | - SubscriptionResultAction; + SubscriptionResultAction | + WriteAction; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 4cc5c35e699..bcd45ec6694 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -55,6 +55,10 @@ import { createStoreReducer, } from '../data/resultReducers'; +import { + DataProxy, +} from '../data/proxy'; + import { isProduction, } from '../util/environment'; @@ -226,12 +230,14 @@ export class QueryManager { optimisticResponse, updateQueries: updateQueriesByName, refetchQueries = [], + update: updateWithProxyFn, }: { mutation: DocumentNode, variables?: Object, optimisticResponse?: Object, updateQueries?: MutationQueryReducersMap, refetchQueries?: string[] | PureQueryOptions[], + update?: (proxy: DataProxy, mutationResult: Object) => void, }): Promise> { const mutationId = this.generateQueryId(); @@ -267,6 +273,7 @@ export class QueryManager { optimisticResponse, extraReducers: this.getExtraReducers(), updateQueries, + update: updateWithProxyFn, }); return new Promise((resolve, reject) => { @@ -288,6 +295,7 @@ export class QueryManager { variables: variables || {}, extraReducers: this.getExtraReducers(), updateQueries, + update: updateWithProxyFn, }); // If there was an error in our reducers, reject this promise! diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 1290150dfc2..934a1d64515 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -8,6 +8,10 @@ import { MutationQueryReducersMap, } from '../data/mutationResults'; +import { + DataProxy, +} from '../data/proxy'; + import { PureQueryOptions, } from './types'; @@ -102,4 +106,5 @@ export interface MutationOptions { optimisticResponse?: Object; updateQueries?: MutationQueryReducersMap; refetchQueries?: string[] | PureQueryOptions[]; + update?: (proxy: DataProxy, mutationResult: Object) => void; } diff --git a/src/data/proxy.ts b/src/data/proxy.ts new file mode 100644 index 00000000000..2e6fe750d5a --- /dev/null +++ b/src/data/proxy.ts @@ -0,0 +1,375 @@ +import { DocumentNode } from 'graphql'; +import { ApolloStore, Store } from '../store'; +import { DataWrite } from '../actions'; +import { IdGetter } from '../core/types'; +import { NormalizedCache } from '../data/storeUtils'; +import { getFragmentQueryDocument } from '../queries/getFromAST'; +import { getDataWithOptimisticResults } from '../optimistic-data/store'; +import { readQueryFromStore } from './readFromStore'; +import { writeResultToStore } from './writeToStore'; + +/** + * A proxy to the normalized data living in our store. This interface allows a + * user to read and write denormalized data which feels natural to the user + * whilst in the background this data is being converted into the normalized + * store format. + */ +export interface DataProxy { + /** + * Reads a GraphQL query from the root query id. + */ + readQuery(config: { + query: DocumentNode, + variables?: Object, + }): QueryType; + + /** + * Reads a GraphQL fragment from any arbitrary id. If there are more then + * one fragments in the provided document then a `fragmentName` must be + * provided to select the correct fragment. + */ + readFragment(config: { + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): FragmentType | null; + + /** + * Writes a GraphQL query to the root query id. + */ + writeQuery(config: { + data: any, + query: DocumentNode, + variables?: Object, + }): void; + + /** + * Writes a GraphQL fragment to any arbitrary id. If there are more then + * one fragments in the provided document then a `fragmentName` must be + * provided to select the correct fragment. + */ + writeFragment(config: { + data: any, + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): void; +} + +/** + * A data proxy that is completely powered by our Redux store. Reads are read + * from the Redux state and writes are dispatched using actions where they will + * update the store. + * + * Needs a Redux store and a selector function to get the Apollo state from the + * root Redux state. + */ +export class ReduxDataProxy implements DataProxy { + /** + * The Redux store that we read and write to. + */ + private store: ApolloStore; + + /** + * A function that selects the Apollo state from Redux state. + */ + private reduxRootSelector: (state: any) => Store; + + constructor( + store: ApolloStore, + reduxRootSelector: (state: any) => Store, + ) { + this.store = store; + this.reduxRootSelector = reduxRootSelector; + } + + /** + * Reads a query from the Redux state. + */ + public readQuery({ + query, + variables, + }: { + query: DocumentNode, + variables?: Object, + }): QueryType { + return readQueryFromStore({ + rootId: 'ROOT_QUERY', + store: getDataWithOptimisticResults(this.reduxRootSelector(this.store.getState())), + query, + variables, + returnPartialData: false, + }); + } + + /** + * Reads a fragment from the Redux state. + */ + public readFragment({ + id, + fragment, + fragmentName, + variables, + }: { + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): FragmentType | null { + const query = getFragmentQueryDocument(fragment, fragmentName); + const data = getDataWithOptimisticResults(this.reduxRootSelector(this.store.getState())); + + // If we could not find an item in the store with the provided id then we + // just return `null`. + if (typeof data[id] === 'undefined') { + return null; + } + + return readQueryFromStore({ + rootId: id, + store: data, + query, + variables, + returnPartialData: false, + }); + } + + /** + * Writes a query to the Redux state. + */ + public writeQuery({ + data, + query, + variables, + }: { + data: any, + query: DocumentNode, + variables?: Object, + }): void { + this.store.dispatch({ + type: 'APOLLO_WRITE', + writes: [{ + rootId: 'ROOT_QUERY', + result: data, + document: query, + variables: variables || {}, + }], + }); + } + + /** + * Writes a fragment to the Redux state. + */ + public writeFragment({ + data, + id, + fragment, + fragmentName, + variables, + }: { + data: any, + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): void { + this.store.dispatch({ + type: 'APOLLO_WRITE', + writes: [{ + rootId: id, + result: data, + document: getFragmentQueryDocument(fragment, fragmentName), + variables: variables || {}, + }], + }); + } +} + +/** + * A data proxy to be used within a transaction. It saves all writes to be + * returned when the transaction finishes. As soon as a transaction is + * constructed it has started. Once a transaction has finished none of its + * methods are usable. + * + * The transaction will read from a single local normalized cache instance and + * it will write to that cache instance as well. + */ +export class TransactionDataProxy implements DataProxy { + /** + * The normalized cache that this transaction reads from. This object will be + * a shallow clone of the `data` object passed into the constructor. + */ + private data: NormalizedCache; + + /** + * Gets a data id from an object. This is used when writing to our local + * cache clone. + */ + private dataIdFromObject: IdGetter; + + /** + * An array of actions that we build up during the life of the transaction. + * Once a transaction finishes the actions array will be returned. + */ + private writes: Array; + + /** + * A boolean flag signaling if the transaction has finished or not. + */ + private isFinished: boolean; + + constructor(data: NormalizedCache, dataIdFromObject: IdGetter = () => null) { + this.data = { ...data }; + this.dataIdFromObject = dataIdFromObject; + this.writes = []; + this.isFinished = false; + } + + /** + * Finishes a transaction and returns the actions accumulated during this + * transaction. The actions are not ready for dispatch in Redux, however. The + * `type` must be added before that. + */ + public finish(): Array { + this.assertNotFinished(); + const writes = this.writes; + this.writes = []; + this.isFinished = true; + return writes; + } + + /** + * Reads a query from the normalized cache. + * + * Throws an error if the transaction has finished. + */ + public readQuery({ + query, + variables, + }: { + query: DocumentNode, + variables?: Object, + }): QueryType { + this.assertNotFinished(); + return readQueryFromStore({ + rootId: 'ROOT_QUERY', + store: this.data, + query, + variables, + returnPartialData: false, + }); + } + + /** + * Reads a fragment from the normalized cache. + * + * Throws an error if the transaction has finished. + */ + public readFragment({ + id, + fragment, + fragmentName, + variables, + }: { + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): FragmentType | null { + this.assertNotFinished(); + const { data } = this; + const query = getFragmentQueryDocument(fragment, fragmentName); + + // If we could not find an item in the store with the provided id then we + // just return `null`. + if (typeof data[id] === 'undefined') { + return null; + } + + return readQueryFromStore({ + rootId: id, + store: data, + query, + variables, + returnPartialData: false, + }); + } + + /** + * Creates a write to be consumed after `finish` is called that instructs + * a write to the store at the root query id. Cannot be called after + * the transaction finishes. + */ + public writeQuery({ + data, + query, + variables, + }: { + data: any, + query: DocumentNode, + variables?: Object, + }): void { + this.assertNotFinished(); + this.applyWrite({ + rootId: 'ROOT_QUERY', + result: data, + document: query, + variables: variables || {}, + }); + } + + /** + * Creates a write to be consumed after `finish` is called that instructs a + * write to the store form some fragment data at an arbitrary id. Cannot be + * called after the transaction finishes. + */ + public writeFragment({ + data, + id, + fragment, + fragmentName, + variables, + }: { + data: any, + id: string, + fragment: DocumentNode, + fragmentName?: string, + variables?: Object, + }): void { + this.assertNotFinished(); + this.applyWrite({ + rootId: id, + result: data, + document: getFragmentQueryDocument(fragment, fragmentName), + variables: variables || {}, + }); + } + + /** + * Throws an error if the transaction has finished. All methods in the + * transaction data proxy should use this method. + */ + private assertNotFinished() { + if (this.isFinished) { + throw new Error('Cannot call transaction methods after the transaction has finished.'); + } + } + + /** + * Takes a write and applies it to our local cache, and adds it to a writes + * array which will be returned later on when the transaction finishes. + */ + private applyWrite(write: DataWrite) { + writeResultToStore({ + result: write.result, + dataId: write.rootId, + document: write.document, + variables: write.variables, + store: this.data, + dataIdFromObject: this.dataIdFromObject, + }); + this.writes.push(write); + } +} diff --git a/src/data/readFromStore.ts b/src/data/readFromStore.ts index e1bd3dae6f8..5891e5d315f 100644 --- a/src/data/readFromStore.ts +++ b/src/data/readFromStore.ts @@ -56,6 +56,7 @@ export type ReadQueryOptions = { variables?: Object, returnPartialData?: boolean, previousResult?: any, + rootId?: string, config?: ApolloReducerConfig, }; @@ -247,6 +248,7 @@ export function diffQueryAgainstStore({ variables, returnPartialData = true, previousResult, + rootId = 'ROOT_QUERY', config, }: ReadQueryOptions): DiffResult { // Throw the right validation error by trying to find a query in the document @@ -264,7 +266,7 @@ export function diffQueryAgainstStore({ const rootIdValue = { type: 'id', - id: 'ROOT_QUERY', + id: rootId, previousResult, }; diff --git a/src/data/store.ts b/src/data/store.ts index 97c5402a7dc..230fab5a305 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -5,12 +5,17 @@ import { isUpdateQueryResultAction, isStoreResetAction, isSubscriptionResultAction, + isWriteAction, } from '../actions'; import { writeResultToStore, } from './writeToStore'; +import { + TransactionDataProxy, +} from '../data/proxy'; + import { QueryStore, } from '../queries/store'; @@ -182,6 +187,26 @@ export function data( }); } + // If the mutation has some writes associated with it then we need to + // apply those writes to the store by running this reducer again with a + // write action. + if (constAction.update) { + const update = constAction.update; + const proxy = new TransactionDataProxy( + newState, + config.dataIdFromObject, + ); + tryFunctionOrLogError(() => update(proxy, constAction.result)); + const writes = proxy.finish(); + newState = data( + newState, + { type: 'APOLLO_WRITE', writes }, + queries, + mutations, + config, + ); + } + // XXX each reducer gets the state from the previous reducer. // Maybe they should all get a clone instead and then compare at the end to make sure it's consistent. if (constAction.extraReducers) { @@ -198,6 +223,20 @@ export function data( // If we are resetting the store, we no longer need any of the data that is currently in // the store so we can just throw it all away. return {}; + } else if (isWriteAction(action)) { + // Simply write our result to the store for this action for all of the + // writes that were specified. + return action.writes.reduce( + (currentState, write) => writeResultToStore({ + result: write.result, + dataId: write.rootId, + document: write.document, + variables: write.variables, + store: currentState, + dataIdFromObject: config.dataIdFromObject, + }), + { ...previousState } as NormalizedCache, + ); } return previousState; diff --git a/src/optimistic-data/store.ts b/src/optimistic-data/store.ts index 07d2d63ff35..e62d48b80ed 100644 --- a/src/optimistic-data/store.ts +++ b/src/optimistic-data/store.ts @@ -1,5 +1,6 @@ import { MutationResultAction, + WriteAction, isMutationInitAction, isMutationResultAction, isMutationErrorAction, @@ -28,11 +29,13 @@ import { import { assign } from '../util/assign'; -// a stack of patches of new or changed documents -export type OptimisticStore = { +export type OptimisticStoreItem = { mutationId: string, data: NormalizedCache, -}[]; +}; + +// a stack of patches of new or changed documents +export type OptimisticStore = OptimisticStoreItem[]; const optimisticDefaultState: any[] = []; @@ -60,13 +63,13 @@ export function optimistic( mutationId: action.mutationId, extraReducers: action.extraReducers, updateQueries: action.updateQueries, + update: action.update, }; - const fakeStore = { + const optimisticData = getDataWithOptimisticResults({ ...store, optimistic: previousState, - }; - const optimisticData = getDataWithOptimisticResults(fakeStore); + }); const patch = getOptimisticDataPatch( optimisticData, @@ -87,29 +90,12 @@ export function optimistic( return newState; } else if ((isMutationErrorAction(action) || isMutationResultAction(action)) && previousState.some(change => change.mutationId === action.mutationId)) { - // Create a shallow copy of the data in the store. - const optimisticData = assign({}, store.data); - - const newState = previousState - // Throw away optimistic changes of that particular mutation - .filter(change => change.mutationId !== action.mutationId) - // Re-run all of our optimistic data actions on top of one another. - .map(change => { - const patch = getOptimisticDataPatch( - optimisticData, - change.action, - store.queries, - store.mutations, - config, - ); - assign(optimisticData, patch); - return { - ...change, - data: patch, - }; - }); - - return newState; + return rollbackOptimisticData( + change => change.mutationId === action.mutationId, + previousState, + store, + config, + ); } return previousState; @@ -117,7 +103,7 @@ export function optimistic( function getOptimisticDataPatch ( previousData: NormalizedCache, - optimisticAction: MutationResultAction, + optimisticAction: MutationResultAction | WriteAction, queries: QueryStore, mutations: MutationStore, config: ApolloReducerConfig, @@ -140,3 +126,42 @@ function getOptimisticDataPatch ( return patch; } + +/** + * Rolls back some optimistic data items depending on the provided filter + * function. In rolling back these items we also re-apply the other optimistic + * data patches to make sure our optimistic data is up to date. + * + * The filter function should return true for all items that we want to + * rollback. + */ +function rollbackOptimisticData ( + filterFn: (item: OptimisticStoreItem) => boolean, + previousState = optimisticDefaultState, + store: any, + config: any, +): OptimisticStore { + // Create a shallow copy of the data in the store. + const optimisticData = assign({}, store.data); + + const newState = previousState + // Throw away optimistic changes of that particular mutation + .filter(item => !filterFn(item)) + // Re-run all of our optimistic data actions on top of one another. + .map(change => { + const patch = getOptimisticDataPatch( + optimisticData, + change.action, + store.queries, + store.mutations, + config, + ); + assign(optimisticData, patch); + return { + ...change, + data: patch, + }; + }); + + return newState; +} diff --git a/src/queries/getFromAST.ts b/src/queries/getFromAST.ts index 58f3ba57ccb..672e1daef0f 100644 --- a/src/queries/getFromAST.ts +++ b/src/queries/getFromAST.ts @@ -147,3 +147,85 @@ export function createFragmentMap(fragments: FragmentDefinitionNode[] = []): Fra return symTable; } + +/** + * Returns a query document which adds a single query operation that only + * spreads the target fragment inside of it. + * + * So for example a document of: + * + * ```graphql + * fragment foo on Foo { a b c } + * ``` + * + * Turns into: + * + * ```graphql + * { ...foo } + * + * fragment foo on Foo { a b c } + * ``` + * + * The target fragment will either be the only fragment in the document, or a + * fragment specified by the provided `fragmentName`. If there is more then one + * fragment, but a `fragmentName` was not defined then an error will be thrown. + */ +export function getFragmentQueryDocument(document: DocumentNode, fragmentName?: string): DocumentNode { + let actualFragmentName = fragmentName; + + // Build an array of all our fragment definitions that will be used for + // validations. We also do some validations on the other definitions in the + // document while building this list. + const fragments: Array = []; + document.definitions.forEach(definition => { + // Throw an error if we encounter an operation definition because we will + // define our own operation definition later on. + if (definition.kind === 'OperationDefinition') { + throw new Error( + `Found a ${definition.operation} operation${definition.name ? ` named '${definition.name.value}'` : ''}. ` + + 'No operations are allowed when using a fragment as a query. Only fragments are allowed.', + ); + } + // Add our definition to the fragments array if it is a fragment + // definition. + if (definition.kind === 'FragmentDefinition') { + fragments.push(definition); + } + }); + + // If the user did not give us a fragment name then let us try to get a + // name from a single fragment in the definition. + if (typeof actualFragmentName === 'undefined') { + if (fragments.length !== 1) { + throw new Error(`Found ${fragments.length} fragments. \`fragmentName\` must be provided when there is not exactly 1 fragment.`); + } + actualFragmentName = fragments[0].name.value; + } + + // Generate a query document with an operation that simply spreads the + // fragment inside of it. + const query: DocumentNode = { + ...document, + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: actualFragmentName, + }, + }, + ], + }, + }, + ...document.definitions, + ], + }; + + return query; +} diff --git a/test/ApolloClient.ts b/test/ApolloClient.ts new file mode 100644 index 00000000000..d2959f42fe1 --- /dev/null +++ b/test/ApolloClient.ts @@ -0,0 +1,732 @@ +import { assert } from 'chai'; +import gql from 'graphql-tag'; +import { Store } from '../src/store'; +import ApolloClient from '../src/ApolloClient'; + +describe('ApolloClient', () => { + describe('readQuery', () => { + it('will read some data from the store', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + }, + }, + }, + }, + }); + + assert.deepEqual(client.readQuery({ query: gql`{ a }` }), { a: 1 }); + assert.deepEqual(client.readQuery({ query: gql`{ b c }` }), { b: 2, c: 3 }); + assert.deepEqual(client.readQuery({ query: gql`{ a b c }` }), { a: 1, b: 2, c: 3 }); + }); + + it('will read some deeply nested data from the store', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }, + }, + }, + }); + + assert.deepEqual( + client.readQuery({ query: gql`{ a d { e } }` }), + { a: 1, d: { e: 4 } }, + ); + assert.deepEqual( + client.readQuery({ query: gql`{ a d { e h { i } } }` }), + { a: 1, d: { e: 4, h: { i: 7 } } }, + ); + assert.deepEqual( + client.readQuery({ query: gql`{ a b c d { e f g h { i j k } } }` }), + { a: 1, b: 2, c: 3, d: { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } } }, + ); + }); + + it('will read some data from the store with variables', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }, + }, + }, + }); + + assert.deepEqual(client.readQuery({ + query: gql`query ($literal: Boolean, $value: Int) { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + }`, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + }); + + describe('readFragment', () => { + it('will throw an error when there is no fragment', () => { + const client = new ApolloClient(); + + assert.throws(() => { + client.readFragment({ id: 'x', fragment: gql`query { a b c }` }); + }, 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.'); + assert.throws(() => { + client.readFragment({ id: 'x', fragment: gql`schema { query: Query }` }); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will throw an error when there is more than one fragment but no fragment name', () => { + const client = new ApolloClient(); + + assert.throws(() => { + client.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b }` }); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + client.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b } fragment c on C { c }` }); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will read some deeply nested data from the store at any id', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + __typename: 'Type2', + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + __typename: 'Type3', + i: 7, + j: 8, + k: 9, + }, + }, + }, + }, + }); + + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e h { i } }` }), + { e: 4, h: { i: 7 } }, + ); + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } }` }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + client.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i }` }), + { i: 7 }, + ); + assert.deepEqual( + client.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i j k }` }), + { i: 7, j: 8, k: 9 }, + ); + assert.deepEqual( + client.readFragment({ + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentFoo', + }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + client.readFragment({ + id: 'bar', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentBar', + }), + { i: 7, j: 8, k: 9 }, + ); + }); + + it('will read some data from the store with variables', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'foo': { + __typename: 'Type1', + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }, + }, + }, + }); + + assert.deepEqual(client.readFragment({ + id: 'foo', + fragment: gql` + fragment foo on Foo { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + + it('will return null when an id that can’t be found is provided', () => { + const client1 = new ApolloClient(); + const client2 = new ApolloClient({ + initialState: { + apollo: { + data: { + 'bar': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }, + }, + }, + }); + const client3 = new ApolloClient({ + initialState: { + apollo: { + data: { + 'foo': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }, + }, + }, + }); + + assert.equal(client1.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.equal(client2.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.deepEqual(client3.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), { a: 1, b: 2, c: 3 }); + }); + }); + + describe('writeQuery', () => { + it('will write some data to the store', () => { + const client = new ApolloClient(); + + client.writeQuery({ data: { a: 1 }, query: gql`{ a }` }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + }, + }); + + client.writeQuery({ data: { b: 2, c: 3 }, query: gql`{ b c }` }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + }, + }); + + client.writeQuery({ data: { a: 4, b: 5, c: 6 }, query: gql`{ a b c }` }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 4, + b: 5, + c: 6, + }, + }); + }); + + it('will write some deeply nested data to the store', () => { + const client = new ApolloClient(); + + client.writeQuery({ + data: { a: 1, d: { e: 4 } }, + query: gql`{ a d { e } }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + }, + }); + + client.writeQuery({ + data: { a: 1, d: { h: { i: 7 } } }, + query: gql`{ a d { h { i } } }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + h: { + type: 'id', + id: '$ROOT_QUERY.d.h', + generated: true, + }, + }, + '$ROOT_QUERY.d.h': { + i: 7, + }, + }); + + client.writeQuery({ + data: { a: 1, b: 2, c: 3, d: { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } } }, + query: gql`{ a b c d { e f g h { i j k } } }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: '$ROOT_QUERY.d.h', + generated: true, + }, + }, + '$ROOT_QUERY.d.h': { + i: 7, + j: 8, + k: 9, + }, + }); + }); + + it('will write some data to the store with variables', () => { + const client = new ApolloClient(); + + client.writeQuery({ + data: { + a: 1, + b: 2, + }, + query: gql` + query ($literal: Boolean, $value: Int) { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + }); + }); + + describe('writeFragment', () => { + it('will throw an error when there is no fragment', () => { + const client = new ApolloClient(); + + assert.throws(() => { + client.writeFragment({ data: {}, id: 'x', fragment: gql`query { a b c }` }); + }, 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.'); + assert.throws(() => { + client.writeFragment({ data: {}, id: 'x', fragment: gql`schema { query: Query }` }); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will throw an error when there is more than one fragment but no fragment name', () => { + const client = new ApolloClient(); + + assert.throws(() => { + client.writeFragment({ data: {}, id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b }` }); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + client.writeFragment({ data: {}, id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b } fragment c on C { c }` }); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will write some deeply nested data into the store at any id', () => { + const client = new ApolloClient({ + dataIdFromObject: (o: any) => o.id, + }); + + client.writeFragment({ + data: { e: 4, h: { id: 'bar', i: 7 } }, + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { e h { i } }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + }, + }); + + client.writeFragment({ + data: { f: 5, g: 6, h: { id: 'bar', j: 8, k: 9 } }, + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { f g h { j k } }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }); + + client.writeFragment({ + data: { i: 10 }, + id: 'bar', + fragment: gql`fragment fragmentBar on Bar { i }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 8, + k: 9, + }, + }); + + client.writeFragment({ + data: { j: 11, k: 12 }, + id: 'bar', + fragment: gql`fragment fragmentBar on Bar { j k }`, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 11, + k: 12, + }, + }); + + client.writeFragment({ + data: { e: 4, f: 5, g: 6, h: { id: 'bar', i: 7, j: 8, k: 9 } }, + id: 'foo', + fragment: gql`fragment fooFragment on Foo { e f g h { i j k } } fragment barFragment on Bar { i j k }`, + fragmentName: 'fooFragment', + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }); + + client.writeFragment({ + data: { i: 10, j: 11, k: 12 }, + id: 'bar', + fragment: gql`fragment fooFragment on Foo { e f g h { i j k } } fragment barFragment on Bar { i j k }`, + fragmentName: 'barFragment', + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 11, + k: 12, + }, + }); + }); + + it('will write some data to the store with variables', () => { + const client = new ApolloClient(); + + client.writeFragment({ + data: { + a: 1, + b: 2, + }, + id: 'foo', + fragment: gql` + fragment foo on Foo { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + }); + }); + + describe('write then read', () => { + it('will write data locally which will then be read back', () => { + const client = new ApolloClient({ + initialState: { + apollo: { + data: { + 'foo': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + bar: { + type: 'id', + id: '$foo.bar', + generated: true, + }, + }, + '$foo.bar': { + __typename: 'Type2', + d: 4, + e: 5, + f: 6, + }, + }, + }, + }, + }); + + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 1, b: 2, c: 3, bar: { d: 4, e: 5, f: 6 } }, + ); + + client.writeFragment({ + id: 'foo', + fragment: gql`fragment x on Foo { a }`, + data: { a: 7 }, + }); + + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 4, e: 5, f: 6 } }, + ); + + client.writeFragment({ + id: 'foo', + fragment: gql`fragment x on Foo { bar { d } }`, + data: { bar: { d: 8 } }, + }); + + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 8, e: 5, f: 6 } }, + ); + + client.writeFragment({ + id: '$foo.bar', + fragment: gql`fragment y on Bar { e }`, + data: { __typename: 'Type2', e: 9 }, + }); + + assert.deepEqual( + client.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 8, e: 9, f: 6 } }, + ); + + assert.deepEqual(client.store.getState().apollo.data, { + 'foo': { + __typename: 'Type1', + a: 7, + b: 2, + c: 3, + bar: { + type: 'id', + id: '$foo.bar', + generated: true, + }, + }, + '$foo.bar': { + __typename: 'Type2', + d: 8, + e: 9, + f: 6, + }, + }); + }); + + it('will write data to a specific id', () => { + const client = new ApolloClient({ + initialState: { apollo: { data: {} } }, + dataIdFromObject: (o: any) => o.id, + }); + + client.writeQuery({ + query: gql`{ a b foo { c d bar { id e f } } }`, + data: { a: 1, b: 2, foo: { c: 3, d: 4, bar: { id: 'foobar', e: 5, f: 6 } } }, + }); + + assert.deepEqual( + client.readQuery({ query: gql`{ a b foo { c d bar { id e f } } }` }), + { a: 1, b: 2, foo: { c: 3, d: 4, bar: { id: 'foobar', e: 5, f: 6 } } }, + ); + + assert.deepEqual(client.store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + foo: { + type: 'id', + id: '$ROOT_QUERY.foo', + generated: true, + }, + }, + '$ROOT_QUERY.foo': { + c: 3, + d: 4, + bar: { + type: 'id', + id: 'foobar', + generated: false, + }, + }, + 'foobar': { + id: 'foobar', + e: 5, + f: 6, + }, + }); + }); + }); +}); diff --git a/test/getFromAST.ts b/test/getFromAST.ts index 5e75cb54756..70272044095 100644 --- a/test/getFromAST.ts +++ b/test/getFromAST.ts @@ -6,6 +6,7 @@ import { createFragmentMap, FragmentMap, getOperationName, + getFragmentQueryDocument, } from '../src/queries/getFromAST'; import { @@ -229,4 +230,106 @@ describe('AST utility functions', () => { getQueryDefinition(queryWithTypeDefination); }, 'Schema type definitions not allowed in queries. Found: "InputObjectTypeDefinition"'); }); + + describe('getFragmentQueryDocument', () => { + it('will throw an error if there is an operation', () => { + assert.throws( + () => getFragmentQueryDocument(gql`{ a b c }`), + 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.', + ); + assert.throws( + () => getFragmentQueryDocument(gql`query { a b c }`), + 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.', + ); + assert.throws( + () => getFragmentQueryDocument(gql`query Named { a b c }`), + 'Found a query operation named \'Named\'. No operations are allowed when using a fragment as a query. Only fragments are allowed.', + ); + assert.throws( + () => getFragmentQueryDocument(gql`mutation Named { a b c }`), + 'Found a mutation operation named \'Named\'. No operations are allowed when using a fragment as a query. ' + + 'Only fragments are allowed.', + ); + assert.throws( + () => getFragmentQueryDocument(gql`subscription Named { a b c }`), + 'Found a subscription operation named \'Named\'. No operations are allowed when using a fragment as a query. ' + + 'Only fragments are allowed.', + ); + }); + + it('will throw an error if there is not exactly one fragment but no `fragmentName`', () => { + assert.throws(() => { + getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + fragment bar on Bar { d e f } + `); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + fragment bar on Bar { d e f } + fragment baz on Baz { g h i } + `); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + getFragmentQueryDocument(gql` + scalar Foo + `); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will create a query document where the single fragment is spread in the root query', () => { + assert.deepEqual( + print(getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + `)), + print(gql` + { ...foo } + fragment foo on Foo { a b c } + `), + ); + }); + + it('will create a query document where the named fragment is spread in the root query', () => { + assert.deepEqual( + print(getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `, 'foo')), + print(gql` + { ...foo } + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `), + ); + assert.deepEqual( + print(getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `, 'bar')), + print(gql` + { ...bar } + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `), + ); + assert.deepEqual( + print(getFragmentQueryDocument(gql` + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `, 'baz')), + print(gql` + { ...baz } + fragment foo on Foo { a b c } + fragment bar on Bar { d e f ...foo } + fragment baz on Baz { g h i ...foo ...bar } + `), + ); + }); + }); }); diff --git a/test/mutationResults.ts b/test/mutationResults.ts index 18fb872b5ea..f5541ca2c48 100644 --- a/test/mutationResults.ts +++ b/test/mutationResults.ts @@ -1196,4 +1196,247 @@ describe('mutation results', () => { done(); }).catch(done); }); + + describe('store transaction updater', () => { + const mutation = gql` + mutation createTodo { + # skipping arguments in the test since they don't matter + createTodo { + id + text + completed + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + createTodo: { + id: '99', + __typename: 'Todo', + text: 'This one was created with a mutation.', + completed: true, + }, + }, + }; + + it('analogous of ARRAY_INSERT', () => { + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + return client.mutate({ + mutation, + update: (proxy, mResult: any) => { + assert.equal(mResult.data.createTodo.id, '99'); + assert.equal(mResult.data.createTodo.text, 'This one was created with a mutation.'); + + const id = 'TodoList5'; + const fragment = gql`fragment todoList on TodoList { todos { id text completed __typename } }`; + + const data: any = proxy.readFragment({ id, fragment }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id, fragment, + }); + }, + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + subscriptionHandle.unsubscribe(); + + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 4); + + // Since we used `prepend` it should be at the front + assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); + }); + }); + + it('does not fail if optional query variables are not supplied', () => { + let subscriptionHandle: Subscription; + const mutationWithVars = gql` + mutation createTodo($requiredVar: String!, $optionalVar: String) { + createTodo(requiredVar: $requiredVar, optionalVar:$optionalVar) { + id + text + completed + __typename + } + __typename + } + `; + + // the test will pass if optionalVar is uncommented + const variables = { + requiredVar: 'x', + // optionalVar: 'y', + }; + return setup({ + request: { + query: mutationWithVars, + variables, + }, + result: mutationResult, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise((resolve, reject) => { + const handle = client.watchQuery({ + query, + variables, + }); + subscriptionHandle = handle.subscribe({ + next(res) { + resolve(res); + }, + }); + }); + }) + .then(() => { + return client.mutate({ + mutation: mutationWithVars, + variables, + update: (proxy, mResult: any) => { + assert.equal(mResult.data.createTodo.id, '99'); + assert.equal(mResult.data.createTodo.text, 'This one was created with a mutation.'); + + const id = 'TodoList5'; + const fragment = gql`fragment todoList on TodoList { todos { id text completed __typename } }`; + + const data: any = proxy.readFragment({ id, fragment }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id, fragment, + }); + }, + }); + }) + .then(() => { + return client.query({query}); + }) + .then((newResult: any) => { + subscriptionHandle.unsubscribe(); + + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 4); + + // Since we used `prepend` it should be at the front + assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); + }); + }); + + it('does not make next queries fail if a mutation fails', (done) => { + const obsHandle = setupObsHandle({ + request: { query: mutation }, + result: {errors: [new Error('mock error')]}, + }, { + request: { query }, + result, + }); + + obsHandle.subscribe({ + next(obj) { + client.mutate({ + mutation, + update: (proxy, mResult: any) => { + assert.equal(mResult.data.createTodo.id, '99'); + assert.equal(mResult.data.createTodo.text, 'This one was created with a mutation.'); + + const id = 'TodoList5'; + const fragment = gql`fragment todoList on TodoList { todos { id text completed __typename } }`; + + const data: any = proxy.readFragment({ id, fragment }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id, fragment, + }); + }, + }) + .then( + () => done(new Error('Mutation should have failed')), + () => client.mutate({ + mutation, + update: (proxy, mResult: any) => { + assert.equal(mResult.data.createTodo.id, '99'); + assert.equal(mResult.data.createTodo.text, 'This one was created with a mutation.'); + + const id = 'TodoList5'; + const fragment = gql`fragment todoList on TodoList { todos { id text completed __typename } }`; + + const data: any = proxy.readFragment({ id, fragment }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id, fragment, + }); + }, + }), + ) + .then( + () => done(new Error('Mutation should have failed')), + () => obsHandle.refetch(), + ) + .then(() => done(), done); + }, + }); + }); + + it('error handling in reducer functions', () => { + const oldError = console.error; + const errors: any[] = []; + console.error = (msg: string) => { + errors.push(msg); + }; + + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + return client.mutate({ + mutation, + update: () => { + throw new Error(`Hello... It's me.`); + }, + }); + }) + .then(() => { + subscriptionHandle.unsubscribe(); + assert.lengthOf(errors, 1); + assert.equal(errors[0].message, `Hello... It's me.`); + console.error = oldError; + }); + }); + }); }); diff --git a/test/optimistic.ts b/test/optimistic.ts index c6f3cc42c13..3efaf7c103b 100644 --- a/test/optimistic.ts +++ b/test/optimistic.ts @@ -189,24 +189,422 @@ describe('optimistic mutation results', () => { }), }); - const updateQueries = { - todoList: (prev: any, options: any) => { - const state = cloneDeep(prev); - state.todoList.todos.unshift(options.mutationResult.data.createTodo); - return state; + describe('with `updateQueries`', () => { + const updateQueries = { + todoList: (prev: any, options: any) => { + const state = cloneDeep(prev); + state.todoList.todos.unshift(options.mutationResult.data.createTodo); + return state; + }, + }; + + it('handles a single error for a single mutation', () => { + return setup({ + request: { query: mutation }, + error: new Error('forbidden (test error)'), + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + updateQueries, + }); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); + assert.equal((dataInStore['Todo99'] as any).text, 'Optimistically generated'); + + return promise; + }) + .catch((err) => { + assert.instanceOf(err, Error); + assert.equal(err.message, 'Network error: forbidden (test error)'); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 3); + assert.notProperty(dataInStore, 'Todo99'); + }); + }); + + it('handles errors produced by one mutation in a series', () => { + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + error: new Error('forbidden (test error)'), + }, { + request: { query: mutation }, + result: mutationResult2, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + updateQueries, + }).catch((err) => { + // it is ok to fail here + assert.instanceOf(err, Error); + assert.equal(err.message, 'Network error: forbidden (test error)'); + return null; + }); + + const promise2 = client.mutate({ + mutation, + optimisticResponse: optimisticResponse2, + updateQueries, + }); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.equal((dataInStore['Todo99'] as any).text, 'Optimistically generated'); + assert.equal((dataInStore['Todo66'] as any).text, 'Optimistically generated 2'); + + return Promise.all([promise, promise2]); + }) + .then(() => { + subscriptionHandle.unsubscribe(); + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); + assert.notProperty(dataInStore, 'Todo99'); + assert.property(dataInStore, 'Todo66'); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); + assert.notInclude((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); + }); + }); + + it('can run 2 mutations concurrently and handles all intermediate states well', () => { + function checkBothMutationsAreApplied(expectedText1: any, expectedText2: any) { + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.property(dataInStore, 'Todo99'); + assert.property(dataInStore, 'Todo66'); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); + assert.equal((dataInStore['Todo99'] as any).text, expectedText1); + assert.equal((dataInStore['Todo66'] as any).text, expectedText2); + } + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + result: mutationResult, + }, { + request: { query: mutation }, + result: mutationResult2, + // make sure it always happens later + delay: 100, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + updateQueries, + }).then((res) => { + checkBothMutationsAreApplied('This one was created with a mutation.', 'Optimistically generated 2'); + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState['5'].loading, false); + assert.equal(mutationsState['6'].loading, true); + + return res; + }); + + const promise2 = client.mutate({ + mutation, + optimisticResponse: optimisticResponse2, + updateQueries, + }).then((res) => { + checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState[5].loading, false); + assert.equal(mutationsState[6].loading, false); + + return res; + }); + + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState[5].loading, true); + assert.equal(mutationsState[6].loading, true); + + checkBothMutationsAreApplied('Optimistically generated', 'Optimistically generated 2'); + + return Promise.all([promise, promise2]); + }) + .then(() => { + subscriptionHandle.unsubscribe(); + checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); + }); + }); + }); + + describe('with `update`', () => { + const update = (proxy: any, mResult: any) => { + const data: any = proxy.readFragment({ + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); + }; + + it('handles a single error for a single mutation', () => { + return setup({ + request: { query: mutation }, + error: new Error('forbidden (test error)'), + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + update, + }); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); + assert.equal((dataInStore['Todo99'] as any).text, 'Optimistically generated'); + + return promise; + }) + .catch((err) => { + assert.instanceOf(err, Error); + assert.equal(err.message, 'Network error: forbidden (test error)'); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 3); + assert.notProperty(dataInStore, 'Todo99'); + }); + }); + + it('handles errors produced by one mutation in a series', () => { + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + error: new Error('forbidden (test error)'), + }, { + request: { query: mutation }, + result: mutationResult2, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + update, + }).catch((err) => { + // it is ok to fail here + assert.instanceOf(err, Error); + assert.equal(err.message, 'Network error: forbidden (test error)'); + return null; + }); + + const promise2 = client.mutate({ + mutation, + optimisticResponse: optimisticResponse2, + update, + }); + + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.equal((dataInStore['Todo99'] as any).text, 'Optimistically generated'); + assert.equal((dataInStore['Todo66'] as any).text, 'Optimistically generated 2'); + + return Promise.all([promise, promise2]); + }) + .then(() => { + subscriptionHandle.unsubscribe(); + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); + assert.notProperty(dataInStore, 'Todo99'); + assert.property(dataInStore, 'Todo66'); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); + assert.notInclude((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); + }); + }); + + it('can run 2 mutations concurrently and handles all intermediate states well', () => { + function checkBothMutationsAreApplied(expectedText1: any, expectedText2: any) { + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.property(dataInStore, 'Todo99'); + assert.property(dataInStore, 'Todo66'); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); + assert.equal((dataInStore['Todo99'] as any).text, expectedText1); + assert.equal((dataInStore['Todo66'] as any).text, expectedText2); + } + let subscriptionHandle: Subscription; + return setup({ + request: { query: mutation }, + result: mutationResult, + }, { + request: { query: mutation }, + result: mutationResult2, + // make sure it always happens later + delay: 100, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); + }) + .then(() => { + const promise = client.mutate({ + mutation, + optimisticResponse, + update, + }).then((res) => { + checkBothMutationsAreApplied('This one was created with a mutation.', 'Optimistically generated 2'); + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState['5'].loading, false); + assert.equal(mutationsState['6'].loading, true); + + return res; + }); + + const promise2 = client.mutate({ + mutation, + optimisticResponse: optimisticResponse2, + update, + }).then((res) => { + checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState[5].loading, false); + assert.equal(mutationsState[6].loading, false); + + return res; + }); + + const mutationsState = client.store.getState().apollo.mutations; + assert.equal(mutationsState[5].loading, true); + assert.equal(mutationsState[6].loading, true); + + checkBothMutationsAreApplied('Optimistically generated', 'Optimistically generated 2'); + + return Promise.all([promise, promise2]); + }) + .then(() => { + subscriptionHandle.unsubscribe(); + checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); + }); + }); + }); + }); + + describe('optimistic updates using `updateQueries`', () => { + const mutation = gql` + mutation createTodo { + # skipping arguments in the test since they don't matter + createTodo { + id + text + completed + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + createTodo: { + id: '99', + __typename: 'Todo', + text: 'This one was created with a mutation.', + completed: true, + }, + }, + }; + + const optimisticResponse = { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + id: '99', + text: 'Optimistically generated', + completed: true, }, }; - it('handles a single error for a single mutation', () => { + const mutationResult2 = { + data: assign({}, mutationResult.data, { + createTodo: assign({}, mutationResult.data.createTodo, { + id: '66', + text: 'Second mutation.', + }), + }), + }; + + const optimisticResponse2 = { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + id: '66', + text: 'Optimistically generated 2', + completed: true, + }, + }; + + it('will insert a single item to the beginning', () => { + let subscriptionHandle: Subscription; return setup({ request: { query: mutation }, - error: new Error('forbidden (test error)'), + result: mutationResult, + }) + .then(() => { + // we have to actually subscribe to the query to be able to update it + return new Promise( (resolve, reject) => { + const handle = client.watchQuery({ query }); + subscriptionHandle = handle.subscribe({ + next(res) { resolve(res); }, + }); + }); }) .then(() => { const promise = client.mutate({ mutation, optimisticResponse, - updateQueries, + updateQueries: { + todoList: (prev, options) => { + const mResult = options.mutationResult as any; + assert.equal(mResult.data.createTodo.id, '99'); + + const state = cloneDeep(prev) as any; + state.todoList.todos.unshift(mResult.data.createTodo); + return state; + }, + }, }); const dataInStore = client.queryManager.getDataWithOptimisticResults(); @@ -215,24 +613,28 @@ describe('optimistic mutation results', () => { return promise; }) - .catch((err) => { - assert.instanceOf(err, Error); - assert.equal(err.message, 'Network error: forbidden (test error)'); + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + subscriptionHandle.unsubscribe(); + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 4); - const dataInStore = client.queryManager.getDataWithOptimisticResults(); - assert.equal((dataInStore['TodoList5'] as any).todos.length, 3); - assert.notProperty(dataInStore, 'Todo99'); + // Since we used `prepend` it should be at the front + assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); }); }); - it('handles errors produced by one mutation in a series', () => { + it('two array insert like mutations', () => { let subscriptionHandle: Subscription; return setup({ request: { query: mutation }, - error: new Error('forbidden (test error)'), + result: mutationResult, }, { request: { query: mutation }, result: mutationResult2, + delay: 50, }) .then(() => { // we have to actually subscribe to the query to be able to update it @@ -244,15 +646,25 @@ describe('optimistic mutation results', () => { }); }) .then(() => { + const updateQueries = { + todoList: (prev, options) => { + const mResult = options.mutationResult as any; + + const state = cloneDeep(prev) as any; + state.todoList.todos.unshift(mResult.data.createTodo); + return state; + }, + } as MutationQueryReducersMap; const promise = client.mutate({ mutation, optimisticResponse, updateQueries, - }).catch((err) => { - // it is ok to fail here - assert.instanceOf(err, Error); - assert.equal(err.message, 'Network error: forbidden (test error)'); - return null; + }).then((res) => { + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.equal((dataInStore['Todo99'] as any).text, 'This one was created with a mutation.'); + assert.equal((dataInStore['Todo66'] as any).text, 'Optimistically generated 2'); + return res; }); const promise2 = client.mutate({ @@ -269,36 +681,34 @@ describe('optimistic mutation results', () => { return Promise.all([promise, promise2]); }) .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { subscriptionHandle.unsubscribe(); - const dataInStore = client.queryManager.getDataWithOptimisticResults(); - assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); - assert.notProperty(dataInStore, 'Todo99'); - assert.property(dataInStore, 'Todo66'); - assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); - assert.notInclude((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 5); + + // Since we used `prepend` it should be at the front + assert.equal(newResult.data.todoList.todos[0].text, 'Second mutation.'); + assert.equal(newResult.data.todoList.todos[1].text, 'This one was created with a mutation.'); }); }); - it('can run 2 mutations concurrently and handles all intermediate states well', () => { - function checkBothMutationsAreApplied(expectedText1: any, expectedText2: any) { - const dataInStore = client.queryManager.getDataWithOptimisticResults(); - assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); - assert.property(dataInStore, 'Todo99'); - assert.property(dataInStore, 'Todo66'); - assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); - assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); - assert.equal((dataInStore['Todo99'] as any).text, expectedText1); - assert.equal((dataInStore['Todo66'] as any).text, expectedText2); - } + it('two mutations, one fails', () => { let subscriptionHandle: Subscription; return setup({ request: { query: mutation }, - result: mutationResult, + error: new Error('forbidden (test error)'), + delay: 20, }, { request: { query: mutation }, result: mutationResult2, - // make sure it always happens later - delay: 100, + // XXX this test will uncover a flaw in the design of optimistic responses combined with + // updateQueries or result reducers if you un-comment the line below. The issue is that + // optimistic updates are not commutative but are treated as such. When undoing an + // optimistic update, other optimistic updates should be rolled back and re-applied in the + // same order as before, otherwise the store can end up in an inconsistent state. + // delay: 50, }) .then(() => { // we have to actually subscribe to the query to be able to update it @@ -310,48 +720,154 @@ describe('optimistic mutation results', () => { }); }) .then(() => { + const updateQueries = { + todoList: (prev, options) => { + const mResult = options.mutationResult as any; + + const state = cloneDeep(prev) as any; + state.todoList.todos.unshift(mResult.data.createTodo); + return state; + }, + } as MutationQueryReducersMap; const promise = client.mutate({ mutation, optimisticResponse, updateQueries, - }).then((res) => { - checkBothMutationsAreApplied('This one was created with a mutation.', 'Optimistically generated 2'); - const mutationsState = client.store.getState().apollo.mutations; - assert.equal(mutationsState['5'].loading, false); - assert.equal(mutationsState['6'].loading, true); - - return res; + }).catch((err) => { + // it is ok to fail here + assert.instanceOf(err, Error); + assert.equal(err.message, 'Network error: forbidden (test error)'); + return null; }); const promise2 = client.mutate({ mutation, optimisticResponse: optimisticResponse2, updateQueries, - }).then((res) => { - checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); - const mutationsState = client.store.getState().apollo.mutations; - assert.equal(mutationsState[5].loading, false); - assert.equal(mutationsState[6].loading, false); - - return res; }); - const mutationsState = client.store.getState().apollo.mutations; - assert.equal(mutationsState[5].loading, true); - assert.equal(mutationsState[6].loading, true); - - checkBothMutationsAreApplied('Optimistically generated', 'Optimistically generated 2'); + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); + assert.equal((dataInStore['Todo99'] as any).text, 'Optimistically generated'); + assert.equal((dataInStore['Todo66'] as any).text, 'Optimistically generated 2'); return Promise.all([promise, promise2]); }) .then(() => { subscriptionHandle.unsubscribe(); - checkBothMutationsAreApplied('This one was created with a mutation.', 'Second mutation.'); + const dataInStore = client.queryManager.getDataWithOptimisticResults(); + assert.equal((dataInStore['TodoList5'] as any).todos.length, 4); + assert.notProperty(dataInStore, 'Todo99'); + assert.property(dataInStore, 'Todo66'); + assert.include((dataInStore['TodoList5'] as any).todos, realIdValue('Todo66')); + assert.notInclude((dataInStore['TodoList5'] as any).todos, realIdValue('Todo99')); }); }); + + it('will handle dependent updates', done => { + networkInterface = mockNetworkInterface({ + request: { query }, + result, + }, { + request: { query: mutation }, + result: mutationResult, + delay: 10, + }, { + request: { query: mutation }, + result: mutationResult2, + delay: 20, + }); + + const customOptimisticResponse1 = { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + id: 'optimistic-99', + text: 'Optimistically generated', + completed: true, + }, + }; + + const customOptimisticResponse2 = { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + id: 'optimistic-66', + text: 'Optimistically generated 2', + completed: true, + }, + }; + + const updateQueries = { + todoList: (prev, options) => { + const mResult = options.mutationResult as any; + + const state = cloneDeep(prev) as any; + state.todoList.todos.unshift(mResult.data.createTodo); + return state; + }, + } as MutationQueryReducersMap; + + client = new ApolloClient({ + networkInterface, + dataIdFromObject: (obj: any) => { + if (obj.id && obj.__typename) { + return obj.__typename + obj.id; + } + return null; + }, + }); + + const defaultTodos = result.data.todoList.todos; + let count = 0; + + client.watchQuery({ query }).subscribe({ + next: (value: any) => { + const todos = value.data.todoList.todos; + switch (count++) { + case 0: + assert.deepEqual(defaultTodos, todos); + twoMutations(); + break; + case 1: + assert.deepEqual([customOptimisticResponse1.createTodo, ...defaultTodos], todos); + break; + case 2: + assert.deepEqual([customOptimisticResponse2.createTodo, customOptimisticResponse1.createTodo, ...defaultTodos], todos); + break; + case 3: + assert.deepEqual([customOptimisticResponse2.createTodo, mutationResult.data.createTodo, ...defaultTodos], todos); + break; + case 4: + assert.deepEqual([mutationResult2.data.createTodo, mutationResult.data.createTodo, ...defaultTodos], todos); + done(); + break; + default: + done(new Error('Next should not have been called again.')); + } + }, + error: error => done(error), + }); + + function twoMutations () { + client.mutate({ + mutation, + optimisticResponse: customOptimisticResponse1, + updateQueries, + }) + .catch(error => done(error)); + + client.mutate({ + mutation, + optimisticResponse: customOptimisticResponse2, + updateQueries, + }) + .catch(error => done(error)); + } + }); }); - describe('optimistic updates using updateQueries', () => { + describe('optimistic updates using `update`', () => { const mutation = gql` mutation createTodo { # skipping arguments in the test since they don't matter @@ -425,15 +941,18 @@ describe('optimistic mutation results', () => { const promise = client.mutate({ mutation, optimisticResponse, - updateQueries: { - todoList: (prev, options) => { - const mResult = options.mutationResult as any; - assert.equal(mResult.data.createTodo.id, '99'); + update: (proxy, mResult: any) => { + assert.equal(mResult.data.createTodo.id, '99'); - const state = cloneDeep(prev) as any; - state.todoList.todos.unshift(mResult.data.createTodo); - return state; - }, + const id = 'TodoList5'; + const fragment = gql`fragment todoList on TodoList { todos { id text completed __typename } }`; + + const data: any = proxy.readFragment({ id, fragment }); + + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id, fragment, + }); }, }); @@ -476,19 +995,22 @@ describe('optimistic mutation results', () => { }); }) .then(() => { - const updateQueries = { - todoList: (prev, options) => { - const mResult = options.mutationResult as any; + const update = (proxy: any, mResult: any) => { + const data: any = proxy.readFragment({ + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); - const state = cloneDeep(prev) as any; - state.todoList.todos.unshift(mResult.data.createTodo); - return state; - }, - } as MutationQueryReducersMap; + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); + }; const promise = client.mutate({ mutation, optimisticResponse, - updateQueries, + update, }).then((res) => { const dataInStore = client.queryManager.getDataWithOptimisticResults(); assert.equal((dataInStore['TodoList5'] as any).todos.length, 5); @@ -500,7 +1022,7 @@ describe('optimistic mutation results', () => { const promise2 = client.mutate({ mutation, optimisticResponse: optimisticResponse2, - updateQueries, + update, }); const dataInStore = client.queryManager.getDataWithOptimisticResults(); @@ -550,19 +1072,22 @@ describe('optimistic mutation results', () => { }); }) .then(() => { - const updateQueries = { - todoList: (prev, options) => { - const mResult = options.mutationResult as any; + const update = (proxy: any, mResult: any) => { + const data: any = proxy.readFragment({ + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); - const state = cloneDeep(prev) as any; - state.todoList.todos.unshift(mResult.data.createTodo); - return state; - }, - } as MutationQueryReducersMap; + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); + }; const promise = client.mutate({ mutation, optimisticResponse, - updateQueries, + update, }).catch((err) => { // it is ok to fail here assert.instanceOf(err, Error); @@ -573,7 +1098,7 @@ describe('optimistic mutation results', () => { const promise2 = client.mutate({ mutation, optimisticResponse: optimisticResponse2, - updateQueries, + update, }); const dataInStore = client.queryManager.getDataWithOptimisticResults(); @@ -628,15 +1153,18 @@ describe('optimistic mutation results', () => { }, }; - const updateQueries = { - todoList: (prev, options) => { - const mResult = options.mutationResult as any; + const update = (proxy: any, mResult: any) => { + const data: any = proxy.readFragment({ + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); - const state = cloneDeep(prev) as any; - state.todoList.todos.unshift(mResult.data.createTodo); - return state; - }, - } as MutationQueryReducersMap; + proxy.writeFragment({ + data: { ...data, todos: [mResult.data.createTodo, ...data.todos] }, + id: 'TodoList5', + fragment: gql`fragment todoList on TodoList { todos { id text completed __typename } }`, + }); + }; client = new ApolloClient({ networkInterface, @@ -683,14 +1211,14 @@ describe('optimistic mutation results', () => { client.mutate({ mutation, optimisticResponse: customOptimisticResponse1, - updateQueries, + update, }) .catch(error => done(error)); client.mutate({ mutation, optimisticResponse: customOptimisticResponse2, - updateQueries, + update, }) .catch(error => done(error)); } diff --git a/test/proxy.ts b/test/proxy.ts new file mode 100644 index 00000000000..a887c786662 --- /dev/null +++ b/test/proxy.ts @@ -0,0 +1,1087 @@ +import { assert } from 'chai'; +import { createStore } from 'redux'; +import gql from 'graphql-tag'; +import { print } from 'graphql-tag/printer'; +import { createApolloStore } from '../src/store'; +import { ReduxDataProxy, TransactionDataProxy } from '../src/data/proxy'; + +describe('ReduxDataProxy', () => { + function createDataProxy({ + initialState, + dataIdFromObject, + }: { + initialState?: any, + dataIdFromObject?: (object: any) => string | null, + } = {}) { + const store = createApolloStore({ + initialState, + config: { dataIdFromObject }, + }); + return new ReduxDataProxy(store, ({ apollo }) => apollo); + } + + describe('readQuery', () => { + it('will read some data from the store', () => { + const proxy = createDataProxy({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + }, + }, + }, + }, + }); + + assert.deepEqual(proxy.readQuery({ query: gql`{ a }` }), { a: 1 }); + assert.deepEqual(proxy.readQuery({ query: gql`{ b c }` }), { b: 2, c: 3 }); + assert.deepEqual(proxy.readQuery({ query: gql`{ a b c }` }), { a: 1, b: 2, c: 3 }); + }); + + it('will read some deeply nested data from the store', () => { + const proxy = createDataProxy({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }, + }, + }, + }); + + assert.deepEqual( + proxy.readQuery({ query: gql`{ a d { e } }` }), + { a: 1, d: { e: 4 } }, + ); + assert.deepEqual( + proxy.readQuery({ query: gql`{ a d { e h { i } } }` }), + { a: 1, d: { e: 4, h: { i: 7 } } }, + ); + assert.deepEqual( + proxy.readQuery({ query: gql`{ a b c d { e f g h { i j k } } }` }), + { a: 1, b: 2, c: 3, d: { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } } }, + ); + }); + + it('will read some data from the store with variables', () => { + const proxy = createDataProxy({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }, + }, + }, + }); + + assert.deepEqual(proxy.readQuery({ + query: gql`query ($literal: Boolean, $value: Int) { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + }`, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + }); + + describe('readFragment', () => { + it('will throw an error when there is no fragment', () => { + const proxy = createDataProxy(); + + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`query { a b c }` }); + }, 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.'); + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`schema { query: Query }` }); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will throw an error when there is more than one fragment but no fragment name', () => { + const proxy = createDataProxy(); + + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b }` }); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b } fragment c on C { c }` }); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will read some deeply nested data from the store at any id', () => { + const proxy = createDataProxy({ + initialState: { + apollo: { + data: { + 'ROOT_QUERY': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + __typename: 'Type2', + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + __typename: 'Type3', + i: 7, + j: 8, + k: 9, + }, + }, + }, + }, + }); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e h { i } }` }), + { e: 4, h: { i: 7 } }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } }` }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i }` }), + { i: 7 }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i j k }` }), + { i: 7, j: 8, k: 9 }, + ); + assert.deepEqual( + proxy.readFragment({ + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentFoo', + }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + proxy.readFragment({ + id: 'bar', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentBar', + }), + { i: 7, j: 8, k: 9 }, + ); + }); + + it('will read some data from the store with variables', () => { + const proxy = createDataProxy({ + initialState: { + apollo: { + data: { + 'foo': { + __typename: 'Type1', + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }, + }, + }, + }); + + assert.deepEqual(proxy.readFragment({ + id: 'foo', + fragment: gql` + fragment foo on Foo { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + + it('will return null when an id that can’t be found is provided', () => { + const client1 = createDataProxy(); + const client2 = createDataProxy({ + initialState: { + apollo: { + data: { + 'bar': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }, + }, + }, + }); + const client3 = createDataProxy({ + initialState: { + apollo: { + data: { + 'foo': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }, + }, + }, + }); + + assert.equal(client1.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.equal(client2.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.deepEqual(client3.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), { a: 1, b: 2, c: 3 }); + }); + }); + + describe('writeQuery', () => { + it('will write some data to the store', () => { + const proxy = createDataProxy(); + + proxy.writeQuery({ data: { a: 1 }, query: gql`{ a }` }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + }, + }); + + proxy.writeQuery({ data: { b: 2, c: 3 }, query: gql`{ b c }` }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + }, + }); + + proxy.writeQuery({ data: { a: 4, b: 5, c: 6 }, query: gql`{ a b c }` }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 4, + b: 5, + c: 6, + }, + }); + }); + + it('will write some deeply nested data to the store', () => { + const proxy = createDataProxy(); + + proxy.writeQuery({ + data: { a: 1, d: { e: 4 } }, + query: gql`{ a d { e } }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + }, + }); + + proxy.writeQuery({ + data: { a: 1, d: { h: { i: 7 } } }, + query: gql`{ a d { h { i } } }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + h: { + type: 'id', + id: '$ROOT_QUERY.d.h', + generated: true, + }, + }, + '$ROOT_QUERY.d.h': { + i: 7, + }, + }); + + proxy.writeQuery({ + data: { a: 1, b: 2, c: 3, d: { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } } }, + query: gql`{ a b c d { e f g h { i j k } } }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: '$ROOT_QUERY.d', + generated: true, + }, + }, + '$ROOT_QUERY.d': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: '$ROOT_QUERY.d.h', + generated: true, + }, + }, + '$ROOT_QUERY.d.h': { + i: 7, + j: 8, + k: 9, + }, + }); + }); + + it('will write some data to the store with variables', () => { + const proxy = createDataProxy(); + + proxy.writeQuery({ + data: { + a: 1, + b: 2, + }, + query: gql` + query ($literal: Boolean, $value: Int) { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'ROOT_QUERY': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + }); + }); + + describe('writeFragment', () => { + it('will throw an error when there is no fragment', () => { + const proxy = createDataProxy(); + + assert.throws(() => { + proxy.writeFragment({ data: {}, id: 'x', fragment: gql`query { a b c }` }); + }, 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.'); + assert.throws(() => { + proxy.writeFragment({ data: {}, id: 'x', fragment: gql`schema { query: Query }` }); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will throw an error when there is more than one fragment but no fragment name', () => { + const proxy = createDataProxy(); + + assert.throws(() => { + proxy.writeFragment({ data: {}, id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b }` }); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + proxy.writeFragment({ data: {}, id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b } fragment c on C { c }` }); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will write some deeply nested data into the store at any id', () => { + const proxy = createDataProxy({ + dataIdFromObject: (o: any) => o.id, + }); + + proxy.writeFragment({ + data: { e: 4, h: { id: 'bar', i: 7 } }, + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { e h { i } }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + }, + }); + + proxy.writeFragment({ + data: { f: 5, g: 6, h: { id: 'bar', j: 8, k: 9 } }, + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { f g h { j k } }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }); + + proxy.writeFragment({ + data: { i: 10 }, + id: 'bar', + fragment: gql`fragment fragmentBar on Bar { i }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 8, + k: 9, + }, + }); + + proxy.writeFragment({ + data: { j: 11, k: 12 }, + id: 'bar', + fragment: gql`fragment fragmentBar on Bar { j k }`, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 11, + k: 12, + }, + }); + + proxy.writeFragment({ + data: { e: 4, f: 5, g: 6, h: { id: 'bar', i: 7, j: 8, k: 9 } }, + id: 'foo', + fragment: gql`fragment fooFragment on Foo { e f g h { i j k } } fragment barFragment on Bar { i j k }`, + fragmentName: 'fooFragment', + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }); + + proxy.writeFragment({ + data: { i: 10, j: 11, k: 12 }, + id: 'bar', + fragment: gql`fragment fooFragment on Foo { e f g h { i j k } } fragment barFragment on Bar { i j k }`, + fragmentName: 'barFragment', + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 10, + j: 11, + k: 12, + }, + }); + }); + + it('will write some data to the store with variables', () => { + const proxy = createDataProxy(); + + proxy.writeFragment({ + data: { + a: 1, + b: 2, + }, + id: 'foo', + fragment: gql` + fragment foo on Foo { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }); + + assert.deepEqual((proxy as any).store.getState().apollo.data, { + 'foo': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + }); + }); +}); + +describe('TransactionDataProxy', () => { + describe('readQuery', () => { + it('will throw an error if the transaction has finished', () => { + const proxy: any = new TransactionDataProxy({}); + proxy.finish(); + + assert.throws(() => { + proxy.readQuery({}); + }, 'Cannot call transaction methods after the transaction has finished.'); + }); + + it('will read some data from the store', () => { + const proxy = new TransactionDataProxy({ + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + }, + }); + + assert.deepEqual(proxy.readQuery({ query: gql`{ a }` }), { a: 1 }); + assert.deepEqual(proxy.readQuery({ query: gql`{ b c }` }), { b: 2, c: 3 }); + assert.deepEqual(proxy.readQuery({ query: gql`{ a b c }` }), { a: 1, b: 2, c: 3 }); + }); + + it('will read some deeply nested data from the store', () => { + const proxy = new TransactionDataProxy({ + 'ROOT_QUERY': { + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + i: 7, + j: 8, + k: 9, + }, + }); + + assert.deepEqual( + proxy.readQuery({ query: gql`{ a d { e } }` }), + { a: 1, d: { e: 4 } }, + ); + assert.deepEqual( + proxy.readQuery({ query: gql`{ a d { e h { i } } }` }), + { a: 1, d: { e: 4, h: { i: 7 } } }, + ); + assert.deepEqual( + proxy.readQuery({ query: gql`{ a b c d { e f g h { i j k } } }` }), + { a: 1, b: 2, c: 3, d: { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } } }, + ); + }); + + it('will read some data from the store with variables', () => { + const proxy = new TransactionDataProxy({ + 'ROOT_QUERY': { + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + + assert.deepEqual(proxy.readQuery({ + query: gql`query ($literal: Boolean, $value: Int) { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + }`, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + }); + + describe('readFragment', () => { + it('will throw an error if the transaction has finished', () => { + const proxy: any = new TransactionDataProxy({}); + proxy.finish(); + + assert.throws(() => { + proxy.readFragment({}); + }, 'Cannot call transaction methods after the transaction has finished.'); + }); + + it('will throw an error when there is no fragment', () => { + const proxy = new TransactionDataProxy({}); + + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`query { a b c }` }); + }, 'Found a query operation. No operations are allowed when using a fragment as a query. Only fragments are allowed.'); + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`schema { query: Query }` }); + }, 'Found 0 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will throw an error when there is more than one fragment but no fragment name', () => { + const proxy = new TransactionDataProxy({}); + + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b }` }); + }, 'Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + assert.throws(() => { + proxy.readFragment({ id: 'x', fragment: gql`fragment a on A { a } fragment b on B { b } fragment c on C { c }` }); + }, 'Found 3 fragments. `fragmentName` must be provided when there is not exactly 1 fragment.'); + }); + + it('will read some deeply nested data from the store at any id', () => { + const proxy = new TransactionDataProxy({ + 'ROOT_QUERY': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + d: { + type: 'id', + id: 'foo', + generated: false, + }, + }, + 'foo': { + __typename: 'Type2', + e: 4, + f: 5, + g: 6, + h: { + type: 'id', + id: 'bar', + generated: false, + }, + }, + 'bar': { + __typename: 'Type3', + i: 7, + j: 8, + k: 9, + }, + }); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e h { i } }` }), + { e: 4, h: { i: 7 } }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } }` }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i }` }), + { i: 7 }, + ); + assert.deepEqual( + proxy.readFragment({ id: 'bar', fragment: gql`fragment fragmentBar on Bar { i j k }` }), + { i: 7, j: 8, k: 9 }, + ); + assert.deepEqual( + proxy.readFragment({ + id: 'foo', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentFoo', + }), + { e: 4, f: 5, g: 6, h: { i: 7, j: 8, k: 9 } }, + ); + assert.deepEqual( + proxy.readFragment({ + id: 'bar', + fragment: gql`fragment fragmentFoo on Foo { e f g h { i j k } } fragment fragmentBar on Bar { i j k }`, + fragmentName: 'fragmentBar', + }), + { i: 7, j: 8, k: 9 }, + ); + }); + + it('will read some data from the store with variables', () => { + const proxy = new TransactionDataProxy({ + 'foo': { + __typename: 'Type1', + 'field({"literal":true,"value":42})': 1, + 'field({"literal":false,"value":42})': 2, + }, + }); + + assert.deepEqual(proxy.readFragment({ + id: 'foo', + fragment: gql` + fragment foo on Foo { + a: field(literal: true, value: 42) + b: field(literal: $literal, value: $value) + } + `, + variables: { + literal: false, + value: 42, + }, + }), { a: 1, b: 2 }); + }); + + it('will return null when an id that can’t be found is provided', () => { + const client1 = new TransactionDataProxy({}); + const client2 = new TransactionDataProxy({ + 'bar': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }); + const client3 = new TransactionDataProxy({ + 'foo': { __typename: 'Type1', a: 1, b: 2, c: 3 }, + }); + + assert.equal(client1.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.equal(client2.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), null); + assert.deepEqual(client3.readFragment({ id: 'foo', fragment: gql`fragment fooFragment on Foo { a b c }` }), { a: 1, b: 2, c: 3 }); + }); + }); + + describe('writeQuery', () => { + it('will throw an error if the transaction has finished', () => { + const proxy: any = new TransactionDataProxy({}); + proxy.finish(); + + assert.throws(() => { + proxy.writeQuery({}); + }, 'Cannot call transaction methods after the transaction has finished.'); + }); + + it('will create writes that get returned when finished', () => { + const proxy = new TransactionDataProxy({}); + + proxy.writeQuery({ + data: { a: 1, b: 2, c: 3 }, + query: gql`{ a b c }`, + }); + + proxy.writeQuery({ + data: { foo: { d: 4, e: 5, bar: { f: 6, g: 7 } } }, + query: gql`{ foo(id: $id) { d e bar { f g } } }`, + variables: { id: 7 }, + }); + + const writes = proxy.finish(); + + assert.deepEqual(writes, [ + { + rootId: 'ROOT_QUERY', + result: { a: 1, b: 2, c: 3 }, + document: gql`{ a b c }`, + variables: {}, + }, + { + rootId: 'ROOT_QUERY', + result: { foo: { d: 4, e: 5, bar: { f: 6, g: 7 } } }, + document: gql`{ foo(id: $id) { d e bar { f g } } }`, + variables: { id: 7 }, + }, + ]); + }); + }); + + describe('writeFragment', () => { + it('will throw an error if the transaction has finished', () => { + const proxy: any = new TransactionDataProxy({}); + proxy.finish(); + + assert.throws(() => { + proxy.writeFragment({}); + }, 'Cannot call transaction methods after the transaction has finished.'); + }); + + it('will create writes that get returned when finished', () => { + const proxy = new TransactionDataProxy({}); + + proxy.writeFragment({ + data: { a: 1, b: 2, c: 3 }, + id: 'foo', + fragment: gql`fragment fragment1 on Foo { a b c }`, + }); + + proxy.writeFragment({ + data: { foo: { d: 4, e: 5, bar: { f: 6, g: 7 } } }, + id: 'bar', + fragment: gql` + fragment fragment1 on Foo { a b c } + fragment fragment2 on Bar { foo(id: $id) { d e bar { f g } } } + `, + fragmentName: 'fragment2', + variables: { id: 7 }, + }); + + const writes = proxy.finish(); + + assert.equal(writes.length, 2); + assert.deepEqual(Object.keys(writes[0]), ['rootId', 'result', 'document', 'variables']); + assert.equal(writes[0].rootId, 'foo'); + assert.deepEqual(writes[0].result, { a: 1, b: 2, c: 3 }); + assert.deepEqual(writes[0].variables, {}); + assert.equal(print(writes[0].document), print(gql` + { ...fragment1 } + fragment fragment1 on Foo { a b c } + `)); + assert.deepEqual(Object.keys(writes[1]), ['rootId', 'result', 'document', 'variables']); + assert.equal(writes[1].rootId, 'bar'); + assert.deepEqual(writes[1].result, { foo: { d: 4, e: 5, bar: { f: 6, g: 7 } } }); + assert.deepEqual(writes[1].variables, { id: 7 }); + assert.equal(print(writes[1].document), print(gql` + { ...fragment2 } + fragment fragment1 on Foo { a b c } + fragment fragment2 on Bar { foo(id: $id) { d e bar { f g } } } + `)); + }); + }); + + describe('write then read', () => { + it('will write data locally which will then be read back', () => { + const data: any = { + 'foo': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + bar: { + type: 'id', + id: '$foo.bar', + generated: true, + }, + }, + '$foo.bar': { + __typename: 'Type2', + d: 4, + e: 5, + f: 6, + }, + }; + + const proxy = new TransactionDataProxy(data); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 1, b: 2, c: 3, bar: { d: 4, e: 5, f: 6 } }, + ); + + proxy.writeFragment({ + id: 'foo', + fragment: gql`fragment x on Foo { a }`, + data: { a: 7 }, + }); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 4, e: 5, f: 6 } }, + ); + + proxy.writeFragment({ + id: 'foo', + fragment: gql`fragment x on Foo { bar { d } }`, + data: { bar: { d: 8 } }, + }); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 8, e: 5, f: 6 } }, + ); + + proxy.writeFragment({ + id: '$foo.bar', + fragment: gql`fragment y on Bar { e }`, + data: { __typename: 'Type2', e: 9 }, + }); + + assert.deepEqual( + proxy.readFragment({ id: 'foo', fragment: gql`fragment x on Foo { a b c bar { d e f } }` }), + { a: 7, b: 2, c: 3, bar: { d: 8, e: 9, f: 6 } }, + ); + + assert.deepEqual((proxy as any).data, { + 'foo': { + __typename: 'Type1', + a: 7, + b: 2, + c: 3, + bar: { + type: 'id', + id: '$foo.bar', + generated: true, + }, + }, + '$foo.bar': { + __typename: 'Type2', + d: 8, + e: 9, + f: 6, + }, + }); + + assert.deepEqual(data, { + 'foo': { + __typename: 'Type1', + a: 1, + b: 2, + c: 3, + bar: { + type: 'id', + id: '$foo.bar', + generated: true, + }, + }, + '$foo.bar': { + __typename: 'Type2', + d: 4, + e: 5, + f: 6, + }, + }); + }); + + it('will write data to a specific id', () => { + const data = {}; + const proxy = new TransactionDataProxy(data, (o: any) => o.id); + + proxy.writeQuery({ + query: gql`{ a b foo { c d bar { id e f } } }`, + data: { a: 1, b: 2, foo: { c: 3, d: 4, bar: { id: 'foobar', e: 5, f: 6 } } }, + }); + + assert.deepEqual( + proxy.readQuery({ query: gql`{ a b foo { c d bar { id e f } } }` }), + { a: 1, b: 2, foo: { c: 3, d: 4, bar: { id: 'foobar', e: 5, f: 6 } } }, + ); + + assert.deepEqual((proxy as any).data, { + 'ROOT_QUERY': { + a: 1, + b: 2, + foo: { + type: 'id', + id: '$ROOT_QUERY.foo', + generated: true, + }, + }, + '$ROOT_QUERY.foo': { + c: 3, + d: 4, + bar: { + type: 'id', + id: 'foobar', + generated: false, + }, + }, + 'foobar': { + id: 'foobar', + e: 5, + f: 6, + }, + }); + + assert.deepEqual(data, {}); + }); + }); +}); diff --git a/test/readFromStore.ts b/test/readFromStore.ts index 891e4dd41ac..07ea322cb04 100644 --- a/test/readFromStore.ts +++ b/test/readFromStore.ts @@ -692,4 +692,91 @@ describe('reading from the store', () => { computedField: 'This is a string!5bit', }); }); + + it('will read from an arbitrary root id', () => { + const data: any = { + id: 'abcd', + stringField: 'This is a string!', + numberField: 5, + nullField: null, + nestedObj: { + id: 'abcde', + stringField: 'This is a string too!', + numberField: 6, + nullField: null, + } as StoreObject, + deepNestedObj: { + stringField: 'This is a deep string', + numberField: 7, + nullField: null, + } as StoreObject, + nullObject: null, + __typename: 'Item', + }; + + const store = { + 'ROOT_QUERY': assign({}, assign({}, omit(data, 'nestedObj', 'deepNestedObj')), { + __typename: 'Query', + nestedObj: { + type: 'id', + id: 'abcde', + generated: false, + }, + }) as StoreObject, + abcde: assign({}, data.nestedObj, { + deepNestedObj: { + type: 'id', + id: 'abcdef', + generated: false, + }, + }) as StoreObject, + abcdef: data.deepNestedObj as StoreObject, + } as NormalizedCache; + + const queryResult1 = readQueryFromStore({ + store, + rootId: 'abcde', + query: gql` + { + stringField + numberField + nullField + deepNestedObj { + stringField + numberField + nullField + } + } + `, + }); + + assert.deepEqual(queryResult1, { + stringField: 'This is a string too!', + numberField: 6, + nullField: null, + deepNestedObj: { + stringField: 'This is a deep string', + numberField: 7, + nullField: null, + }, + }); + + const queryResult2 = readQueryFromStore({ + store, + rootId: 'abcdef', + query: gql` + { + stringField + numberField + nullField + } + `, + }); + + assert.deepEqual(queryResult2, { + stringField: 'This is a deep string', + numberField: 7, + nullField: null, + }); + }); }); diff --git a/test/store.ts b/test/store.ts index e0f88b255cd..8cdc7bab060 100644 --- a/test/store.ts +++ b/test/store.ts @@ -251,8 +251,9 @@ describe('createApolloStore', () => { operationName: 'Increment', variables: {}, mutationId: '1', - extraReducers: undefined as undefined, - updateQueries: undefined as undefined, + extraReducers: undefined, + updateQueries: undefined, + update: undefined, }, }, ], diff --git a/test/tests.ts b/test/tests.ts index 5fa1ffe9cde..12d3e808e46 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -52,4 +52,6 @@ import './customResolvers'; import './isEqual'; import './cloneDeep'; import './assign'; -import './environment' +import './environment'; +import './ApolloClient'; +import './proxy';