From 71934a60ac733ddca7b65e3dd5a3746b046145aa Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 10:24:43 -0700 Subject: [PATCH 01/36] Set up some tests --- test/mocks/mockNetworkInterface.ts | 4 ++ test/mutations.ts | 70 ++++++++++++++++++++++++++++++ test/tests.ts | 1 + 3 files changed, 75 insertions(+) create mode 100644 test/mutations.ts diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index d20ee9fbf86..f8f336fe03d 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -67,7 +67,11 @@ export class MockNetworkInterface implements NetworkInterface { const key = requestToKey(parsedRequest); const responses = this.mockedResponsesByKey[key]; +<<<<<<< HEAD if (!responses || responses.length === 0) { +======= + if (!this.mockedResponsesByKey[key] || this.mockedResponsesByKey[key].length === 0) { +>>>>>>> Set up some tests throw new Error('No more mocked responses for the query: ' + request.query); } diff --git a/test/mutations.ts b/test/mutations.ts new file mode 100644 index 00000000000..82f6597d131 --- /dev/null +++ b/test/mutations.ts @@ -0,0 +1,70 @@ +import { assert } from 'chai'; +import mockNetworkInterface from './mocks/mockNetworkInterface'; +import ApolloClient from '../src'; + +import gql from 'graphql-tag'; + +describe('mutation results', () => { + const query = gql` + query todoList { + todoList(id: 5) { + id + todos { + id + text + completed + } + } + } + `; + + const result = { + data: { + todoList: { + __typename: 'TodoList', + id: '5', + todos: [ + { + __typename: 'Todo', + id: '3', + text: 'Hello world', + completed: false, + }, + { + __typename: 'Todo', + id: '6', + text: 'Second task', + completed: false, + }, + { + __typename: 'Todo', + id: '12', + text: 'Do other stuff', + completed: false, + }, + ], + }, + }, + }; + + let client; + let networkInterface; + beforeEach((done) => { + networkInterface = mockNetworkInterface({ + request: { query }, + result, + }); + + client = new ApolloClient({ networkInterface }); + + client.query({ + query, + }).then(() => done()); + }); + + it('correctly primes cache for tests', () => { + return client.query({ + query, + }); + }); +}); diff --git a/test/tests.ts b/test/tests.ts index b5531714a7d..19a5cbd29bf 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -22,3 +22,4 @@ import './directives'; import './queryMerging'; import './batching'; import './scheduler'; +import './mutations'; From a896130187528309fdf88e59379cdcfe0b0d9d4e Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 12:31:30 -0700 Subject: [PATCH 02/36] Fix stuff --- test/mocks/mockNetworkInterface.ts | 8 +++----- test/mutations.ts | 26 ++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index f8f336fe03d..5ac3dd2d8dc 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -10,6 +10,8 @@ import { print, } from 'graphql'; +import { print } from 'graphql/language/printer'; + // Pass in multiple mocked responses, so that you can test flows that end up // making multiple queries to the server export default function mockNetworkInterface( @@ -67,12 +69,8 @@ export class MockNetworkInterface implements NetworkInterface { const key = requestToKey(parsedRequest); const responses = this.mockedResponsesByKey[key]; -<<<<<<< HEAD if (!responses || responses.length === 0) { -======= - if (!this.mockedResponsesByKey[key] || this.mockedResponsesByKey[key].length === 0) { ->>>>>>> Set up some tests - throw new Error('No more mocked responses for the query: ' + request.query); + throw new Error('No more mocked responses for the query: ' + print(request.query)); } const { result, error, delay } = responses.shift(); diff --git a/test/mutations.ts b/test/mutations.ts index 82f6597d131..2ef6689c085 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; -import ApolloClient from '../src'; +import ApolloClient, { addTypename } from '../src'; import gql from 'graphql-tag'; @@ -13,13 +13,17 @@ describe('mutation results', () => { id text completed + __typename } + __typename } + __typename } `; const result = { data: { + __typename: 'Query', todoList: { __typename: 'TodoList', id: '5', @@ -55,11 +59,25 @@ describe('mutation results', () => { result, }); - client = new ApolloClient({ networkInterface }); + client = new ApolloClient({ + networkInterface, + // XXX right now this isn't compatible with our mocking + // strategy... + // FIX BEFORE PR MERGE + // queryTransformer: addTypename, + dataIdFromObject: (obj: any) => { + if (obj.id && obj.__typename) { + return obj.__typename + obj.id; + } + return null; + }, + }); - client.query({ + return client.query({ query, - }).then(() => done()); + }) + .then(() => done()) + .catch((e) => console.log(e)); }); it('correctly primes cache for tests', () => { From f9a89e2df24fe4a31a446a3f715dab16990bb00c Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 12:41:40 -0700 Subject: [PATCH 03/36] Write test for already working mutation --- test/mutations.ts | 56 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/test/mutations.ts b/test/mutations.ts index 2ef6689c085..3c801c84a37 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -51,13 +51,14 @@ describe('mutation results', () => { }, }; - let client; + let client: ApolloClient; let networkInterface; - beforeEach((done) => { + + function setup(...mockedResponses) { networkInterface = mockNetworkInterface({ request: { query }, result, - }); + }, ...mockedResponses); client = new ApolloClient({ networkInterface, @@ -75,14 +76,51 @@ describe('mutation results', () => { return client.query({ query, - }) - .then(() => done()) - .catch((e) => console.log(e)); - }); + }); + }; it('correctly primes cache for tests', () => { - return client.query({ - query, + return setup() + .then(() => client.query({ + query, + })); + }); + + it('correctly integrates field changes by default', () => { + const mutation = gql` + mutation setCompleted { + setCompleted(todoId: "3") { + id + completed + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + setCompleted: { + __typename: 'Todo', + id: '3', + completed: true, + } + } + }; + + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ mutation }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + assert.isTrue(newResult.data.todoList.todos[0].completed); }); }); }); From e49d027414624cfcd10fd950b1846dc69a139640 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 12:50:53 -0700 Subject: [PATCH 04/36] Failing test for array insert --- test/mutations.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/mutations.ts b/test/mutations.ts index 3c801c84a37..2de4aea688b 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -123,4 +123,59 @@ describe('mutation results', () => { assert.isTrue(newResult.data.todoList.todos[0].completed); }); }); + + describe('ARRAY_INSERT', () => { + it('correctly integrates a basic object', () => { + 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: { + __typename: 'Todo', + id: '99', + text: 'This one was created with a mutation.', + completed: true, + } + } + }; + + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [{ + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ '5', 'todos' ], + where: 'prepend', + }] + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // 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'); + }); + }); + }); }); From f9eec6b0fd0b533432be4e4ec349d9be94aae3b8 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 13:19:14 -0700 Subject: [PATCH 05/36] Get to a reasonable failing state --- src/QueryManager.ts | 7 +++++++ src/actions.ts | 5 +++++ src/data/store.ts | 27 +++++++++++++++++++++++++++ src/index.ts | 5 +++++ src/store.ts | 5 +++++ test/mocks/mockNetworkInterface.ts | 2 -- test/mutations.ts | 2 +- 7 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/QueryManager.ts b/src/QueryManager.ts index f4d3bfdf255..90c04d6395f 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -47,6 +47,10 @@ import { diffSelectionSetAgainstStore, } from './data/diffAgainstStore'; +import { + MutationApplyResultAction, +} from './data/mutationResultActions'; + import { queryDocument, } from './queryPrinting'; @@ -193,9 +197,11 @@ export class QueryManager { public mutate({ mutation, variables, + applyResult, }: { mutation: Document, variables?: Object, + applyResult?: MutationApplyResultAction[], }): Promise { const mutationId = this.generateQueryId(); @@ -232,6 +238,7 @@ export class QueryManager { type: 'APOLLO_MUTATION_RESULT', result, mutationId, + applyResult, }); return result; diff --git a/src/actions.ts b/src/actions.ts index 5c1b1d1707c..a8c88790b2d 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -6,6 +6,10 @@ import { SelectionSetWithRoot, } from './queries/store'; +import { + MutationApplyResultAction, +} from './data/mutationResultActions'; + import { FragmentMap } from './queries/getFromAST'; export interface QueryResultAction { @@ -85,6 +89,7 @@ export interface MutationResultAction { type: 'APOLLO_MUTATION_RESULT'; result: GraphQLResult; mutationId: string; + applyResult?: MutationApplyResultAction[]; } export function isMutationResultAction(action: ApolloAction): action is MutationResultAction { diff --git a/src/data/store.ts b/src/data/store.ts index 9db51546102..ad090a43160 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -27,6 +27,14 @@ import { graphQLResultHasError, } from './storeUtils'; +import { + MutationApplyResultAction, +} from './mutationResultActions'; + +import { + GraphQLResult, +} from 'graphql'; + export interface NormalizedCache { [dataId: string]: StoreObject; } @@ -38,6 +46,16 @@ export interface StoreObject { export type StoreValue = number | string | string[]; +export type MutationResultReducerMap = { + [type: string]: MutationResultReducer; +} + +export type MutationResultReducer = ( + state: NormalizedCache, + action: MutationApplyResultAction, + result: GraphQLResult +) => NormalizedCache; + export function data( previousState: NormalizedCache = {}, action: ApolloAction, @@ -94,6 +112,15 @@ export function data( fragmentMap: queryStoreValue.fragmentMap, }); + if (action.applyResult) { + action.applyResult.forEach((applyResultAction) => { + if (!config.mutationResultReducers || + !config.mutationResultReducers[applyResultAction.type]) { + throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); + } + }); + } + return newState; } } else if (isStoreResetAction(action)) { diff --git a/src/index.ts b/src/index.ts index 057cf5be6ac..2c9eb0c3c8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,10 @@ import { addTypenameToSelectionSet, } from './queries/queryTransform'; +import { + MutationApplyResultAction, +} from './data/mutationResultActions'; + import isUndefined = require('lodash.isundefined'); export { @@ -117,6 +121,7 @@ export default class ApolloClient { public mutate = (options: { mutation: Document, + applyResult?: MutationApplyResultAction[], variables?: Object, }): Promise => { this.initStore(); diff --git a/src/store.ts b/src/store.ts index 4d4bc3d2dc5..3bd84278c91 100644 --- a/src/store.ts +++ b/src/store.ts @@ -28,6 +28,10 @@ import { IdGetter, } from './data/extensions'; +import { + MutationResultReducerMap, +} from './data/store'; + export interface Store { data: NormalizedCache; queries: QueryStore; @@ -106,4 +110,5 @@ export function createApolloStore({ export interface ApolloReducerConfig { dataIdFromObject?: IdGetter; + mutationResultReducers?: MutationResultReducerMap; } diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index 5ac3dd2d8dc..6b9792e2884 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -10,8 +10,6 @@ import { print, } from 'graphql'; -import { print } from 'graphql/language/printer'; - // Pass in multiple mocked responses, so that you can test flows that end up // making multiple queries to the server export default function mockNetworkInterface( diff --git a/test/mutations.ts b/test/mutations.ts index 2de4aea688b..5bc3b88b99e 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -162,7 +162,7 @@ describe('mutation results', () => { type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], storePath: [ '5', 'todos' ], - where: 'prepend', + where: 'PREPEND', }] }); }) From 6fc9190898a24f50849971d8c213fec475f3b5d5 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 13:19:26 -0700 Subject: [PATCH 06/36] Commit new file --- src/data/mutationResultActions.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/data/mutationResultActions.ts diff --git a/src/data/mutationResultActions.ts b/src/data/mutationResultActions.ts new file mode 100644 index 00000000000..d338280d094 --- /dev/null +++ b/src/data/mutationResultActions.ts @@ -0,0 +1,13 @@ +export type MutationApplyResultAction = + MutationArrayInsertAction; + +export type MutationArrayInsertAction = { + type: 'ARRAY_INSERT'; + resultPath: string[]; + storePath: string[]; + where: ArrayInsertWhere; +} + +export type ArrayInsertWhere = + 'PREPEND' | + 'APPEND'; From ebbd0f4204d6a60b08900b8975830dd55930f99a Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 15:36:30 -0700 Subject: [PATCH 07/36] WIP --- src/data/store.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/data/store.ts b/src/data/store.ts index ad090a43160..34997e1f941 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -31,6 +31,10 @@ import { MutationApplyResultAction, } from './mutationResultActions'; +import { + defaultMutationResultReducers, +} from './mutationResultReducers'; + import { GraphQLResult, } from 'graphql'; @@ -53,7 +57,8 @@ export type MutationResultReducerMap = { export type MutationResultReducer = ( state: NormalizedCache, action: MutationApplyResultAction, - result: GraphQLResult + result: GraphQLResult, + mutation: Document, ) => NormalizedCache; export function data( @@ -102,7 +107,7 @@ export function data( // XXX use immutablejs instead of cloning const clonedState = assign({}, previousState) as NormalizedCache; - const newState = writeSelectionSetToStore({ + let newState = writeSelectionSetToStore({ result: action.result.data, dataId: queryStoreValue.mutation.id, selectionSet: queryStoreValue.mutation.selectionSet, @@ -114,8 +119,14 @@ export function data( if (action.applyResult) { action.applyResult.forEach((applyResultAction) => { - if (!config.mutationResultReducers || - !config.mutationResultReducers[applyResultAction.type]) { + if (defaultMutationResultReducers[applyResultAction.type]) { + console.log(queryStoreValue.mutation.selectionSet.selections[0]) + newState = defaultMutationResultReducers[applyResultAction.type]( + newState, + applyResultAction, + action.result + ); + } else { throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); } }); From d59657b3d953b163a6e3081d8e5ff71da6e7f474 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 15:36:39 -0700 Subject: [PATCH 08/36] Missing file --- src/data/mutationResultReducers.ts | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/data/mutationResultReducers.ts diff --git a/src/data/mutationResultReducers.ts b/src/data/mutationResultReducers.ts new file mode 100644 index 00000000000..09b592aefba --- /dev/null +++ b/src/data/mutationResultReducers.ts @@ -0,0 +1,35 @@ +import { + MutationArrayInsertAction, +} from './mutationResultActions'; + +import { + NormalizedCache, +} from './store'; + +import { + GraphQLResult, + Document, +} from 'graphql'; + +function mutationResultArrayInsertReducer( + state: NormalizedCache, + action: MutationArrayInsertAction, + result: GraphQLResult +) { + const { + resultPath, + storePath, + where, + } = action; + + // Step 1: get selection set and result for resultPath + // This might actually be a relatively complex operation, on the level of + // writing a query... perhaps we can factor that out + // Note: this is also necessary for incorporating defer results..! + // Step 2: insert object into store with writeSelectionSet + // Step 3: insert dataId reference into storePath array +} + +export const defaultMutationResultReducers = { + 'ARRAY_INSERT': mutationResultArrayInsertReducer, +}; From 14f8d2946b057a6fff663977e7eea8cf5fc83e4b Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 16:10:03 -0700 Subject: [PATCH 09/36] Start on scoping query selection set --- src/data/scopeQuery.ts | 67 ++++++++++++++++++++++++++++++++++ src/data/store.ts | 3 +- test/scopeQuery.ts | 83 ++++++++++++++++++++++++++++++++++++++++++ test/tests.ts | 1 + 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/data/scopeQuery.ts create mode 100644 test/scopeQuery.ts diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts new file mode 100644 index 00000000000..399579e62ba --- /dev/null +++ b/src/data/scopeQuery.ts @@ -0,0 +1,67 @@ +import { + FragmentMap, +} from '../queries/getFromAST'; + +import { + SelectionSet, + Field, +} from 'graphql'; + +import { + isField, + isInlineFragment, +} from './storeUtils'; + +import cloneDeep = require('lodash.clonedeep'); +import isNumber = require('lodash.isnumber'); + +export function scopeSelectionSetToResultPath({ + selectionSet, + fragmentMap, + path, +}: { + selectionSet: SelectionSet, + fragmentMap?: FragmentMap, + // Path segment is string for objects, number for arrays + path: (string | number)[], +}): SelectionSet { + let currSelSet = selectionSet; + + path + // Arrays are not represented in GraphQL AST + .filter((pathSegment) => !isNumber(pathSegment)) + .forEach((pathSegment) => { + currSelSet = followOnePathSegment(currSelSet, pathSegment as string); + }); + + return currSelSet; +} + +function followOnePathSegment(currSelSet: SelectionSet, pathSegment: string): SelectionSet { + const matchingFields: Field[] = getMatchingFields(currSelSet, pathSegment); + + if (matchingFields.length < 1) { + throw new Error(`No matching field found in query for path segment: ${pathSegment}`); + } + + if (matchingFields.length > 1) { + throw new Error(`Multiple fields found in query for path segment: ${pathSegment}. \ + Please file an issue on Apollo Client if you run into this situation.`); + } + + return matchingFields[0].selectionSet; +} + +function getMatchingFields(currSelSet: SelectionSet, pathSegment: string): Field[] { + const matching = []; + + currSelSet.selections.forEach((selection) => { + if (isField(selection)) { + if (selection.name.value === pathSegment) { + matching.push(selection); + } + } + }); + + return matching; +} diff --git a/src/data/store.ts b/src/data/store.ts index 34997e1f941..fa2079edf09 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -58,7 +58,7 @@ export type MutationResultReducer = ( state: NormalizedCache, action: MutationApplyResultAction, result: GraphQLResult, - mutation: Document, + mutation: Document ) => NormalizedCache; export function data( @@ -120,7 +120,6 @@ export function data( if (action.applyResult) { action.applyResult.forEach((applyResultAction) => { if (defaultMutationResultReducers[applyResultAction.type]) { - console.log(queryStoreValue.mutation.selectionSet.selections[0]) newState = defaultMutationResultReducers[applyResultAction.type]( newState, applyResultAction, diff --git a/test/scopeQuery.ts b/test/scopeQuery.ts new file mode 100644 index 00000000000..02877510748 --- /dev/null +++ b/test/scopeQuery.ts @@ -0,0 +1,83 @@ +import { assert } from 'chai'; +import { scopeSelectionSetToResultPath } from '../src/data/scopeQuery'; + +import { + createFragmentMap, + getFragmentDefinitions, + getQueryDefinition, + getMutationDefinition, + getFragmentDefinition, + FragmentMap, +} from '../src/queries/getFromAST'; + +import gql from 'graphql-tag'; + +import { + print, + Document, +} from 'graphql'; + +// To test: +// 1. basic +// 2. aliases +// 3. arguments +// 4. fragments + +describe('scoping selection set', () => { + it('scopes a basic selection set', () => { + testScope( + gql` + { + a { + b + } + } + `, + gql` + { + b + } + `, + ['a'] + ); + }); +}); + +function extractMainSelectionSet(doc) { + let mainDefinition; + + try { + mainDefinition = getQueryDefinition(doc); + } catch (e) { + try { + mainDefinition = getMutationDefinition(doc); + } catch (e) { + try { + mainDefinition = getFragmentDefinition(doc); + } catch (e) { + throw new Error('Could not find query, mutation, or fragment in document.'); + } + } + } + + return mainDefinition.selectionSet; +} + +function scope(doc: Document, path: (string | number)[]) { + const fragmentMap = createFragmentMap(getFragmentDefinitions(doc)); + + const selectionSet = extractMainSelectionSet(doc); + + return scopeSelectionSetToResultPath({ + selectionSet, + fragmentMap, + path, + }); +} + +function testScope(firstDoc, secondDoc, path) { + assert.equal( + print(scope(firstDoc, path)).trim(), + print(secondDoc).trim() + ); +} diff --git a/test/tests.ts b/test/tests.ts index 19a5cbd29bf..8138d1ab465 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -23,3 +23,4 @@ import './queryMerging'; import './batching'; import './scheduler'; import './mutations'; +import './scopeQuery'; From e24a4d545ca995410f6ccf82f22481eb884cab94 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 16:20:57 -0700 Subject: [PATCH 10/36] Finish scoping --- src/data/scopeQuery.ts | 32 +++++++--- test/scopeQuery.ts | 131 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts index 399579e62ba..e3683137f6c 100644 --- a/src/data/scopeQuery.ts +++ b/src/data/scopeQuery.ts @@ -10,6 +10,7 @@ import { import { isField, isInlineFragment, + resultKeyNameFromField, } from './storeUtils'; import cloneDeep = require('lodash.clonedeep'); @@ -31,14 +32,18 @@ export function scopeSelectionSetToResultPath({ // Arrays are not represented in GraphQL AST .filter((pathSegment) => !isNumber(pathSegment)) .forEach((pathSegment) => { - currSelSet = followOnePathSegment(currSelSet, pathSegment as string); + currSelSet = followOnePathSegment(currSelSet, pathSegment as string, fragmentMap); }); return currSelSet; } -function followOnePathSegment(currSelSet: SelectionSet, pathSegment: string): SelectionSet { - const matchingFields: Field[] = getMatchingFields(currSelSet, pathSegment); +function followOnePathSegment( + currSelSet: SelectionSet, + pathSegment: string, + fragmentMap: FragmentMap +): SelectionSet { + const matchingFields: Field[] = getMatchingFields(currSelSet, pathSegment, fragmentMap); if (matchingFields.length < 1) { throw new Error(`No matching field found in query for path segment: ${pathSegment}`); @@ -52,14 +57,25 @@ function followOnePathSegment(currSelSet: SelectionSet, pathSegment: string): Se return matchingFields[0].selectionSet; } -function getMatchingFields(currSelSet: SelectionSet, pathSegment: string): Field[] { - const matching = []; +function getMatchingFields( + currSelSet: SelectionSet, + pathSegment: string, + fragmentMap: FragmentMap +): Field[] { + let matching = []; currSelSet.selections.forEach((selection) => { if (isField(selection)) { - if (selection.name.value === pathSegment) { - matching.push(selection); - } + if (resultKeyNameFromField(selection) === pathSegment) { + matching.push(selection); + } + } else if (isInlineFragment(selection)) { + matching = matching.concat( + getMatchingFields(selection.selectionSet, pathSegment, fragmentMap)); + } else { // is named fragment + const fragment = fragmentMap[selection.name.value]; + matching = matching.concat( + getMatchingFields(fragment.selectionSet, pathSegment, fragmentMap)); } }); diff --git a/test/scopeQuery.ts b/test/scopeQuery.ts index 02877510748..ef0f42912ac 100644 --- a/test/scopeQuery.ts +++ b/test/scopeQuery.ts @@ -7,7 +7,6 @@ import { getQueryDefinition, getMutationDefinition, getFragmentDefinition, - FragmentMap, } from '../src/queries/getFromAST'; import gql from 'graphql-tag'; @@ -22,20 +21,148 @@ import { // 2. aliases // 3. arguments // 4. fragments +// 5. directives describe('scoping selection set', () => { - it('scopes a basic selection set', () => { + it('basic', () => { testScope( gql` { a { b + c { + d + } } } `, gql` { b + c { + d + } + } + `, + ['a'] + ); + + testScope( + gql` + { + a { + b + c { + d + } + } + } + `, + gql` + { + d + } + `, + ['a', 'c'] + ); + }); + + it('directives', () => { + testScope( + gql` + { + a @defer { + b + c @live { + d + } + } + } + `, + gql` + { + b + c @live { + d + } + } + `, + ['a'] + ); + }); + + it('alias', () => { + testScope( + gql` + { + alias: a { + b + c { + d + } + } + } + `, + gql` + { + b + c { + d + } + } + `, + ['alias'] + ); + }); + + it('inline fragment', () => { + testScope( + gql` + { + ... on Query { + a { + b + c { + d + } + } + } + } + `, + gql` + { + b + c { + d + } + } + `, + ['a'] + ); + }); + + it('named fragment', () => { + testScope( + gql` + { + ...Frag + } + + fragment Frag on Query { + a { + b + c { + d + } + } + } + `, + gql` + { + b + c { + d + } } `, ['a'] From c2f5cae26d533c0ffc395d640b6317a06397c74f Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 17:15:22 -0700 Subject: [PATCH 11/36] Implement basic ARRAY_INSERT --- src/data/mutationResultReducers.ts | 90 +++++++++++++++++++++++++++--- src/data/scopeQuery.ts | 15 ++++- src/data/store.ts | 28 ++++------ src/store.ts | 2 +- test/mutations.ts | 4 +- 5 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/data/mutationResultReducers.ts b/src/data/mutationResultReducers.ts index 09b592aefba..46b6fb59b07 100644 --- a/src/data/mutationResultReducers.ts +++ b/src/data/mutationResultReducers.ts @@ -8,14 +8,35 @@ import { import { GraphQLResult, - Document, + SelectionSet, } from 'graphql'; -function mutationResultArrayInsertReducer( - state: NormalizedCache, - action: MutationArrayInsertAction, - result: GraphQLResult -) { +import { + FragmentMap, +} from '../queries/getFromAST'; + +import { + scopeSelectionSetToResultPath, + scopeJSONToResultPath, +} from './scopeQuery'; + +import { + ApolloReducerConfig, +} from '../store'; + +import { + writeSelectionSetToStore, +} from './writeToStore'; + +function mutationResultArrayInsertReducer({ + state, + action, + result, + variables, + fragmentMap, + selectionSet, + config, +}: MutationResultReducerArgs): NormalizedCache { const { resultPath, storePath, @@ -26,10 +47,65 @@ function mutationResultArrayInsertReducer( // This might actually be a relatively complex operation, on the level of // writing a query... perhaps we can factor that out // Note: this is also necessary for incorporating defer results..! + const scopedSelectionSet = scopeSelectionSetToResultPath({ + selectionSet, + fragmentMap, + path: resultPath, + }); + + const scopedResult = scopeJSONToResultPath({ + json: result.data, + path: resultPath, + }); + + // OK, now we need to get a dataID to pass to writeSelectionSetToStore + // XXX generate dataID here! + const dataId = config.dataIdFromObject(scopedResult) || 'xxx'; + // Step 2: insert object into store with writeSelectionSet + state = writeSelectionSetToStore({ + result: scopedResult, + dataId, + selectionSet: scopedSelectionSet, + store: state, + variables, + dataIdFromObject: config.dataIdFromObject, + fragmentMap, + }); + + console.log(JSON.stringify(state, null, 2)); + // Step 3: insert dataId reference into storePath array + const array = scopeJSONToResultPath({ + json: state, + path: storePath, + }); + + if (where === 'PREPEND') { + array.unshift(dataId); + } else { + throw new Error('Unsupported "where" option to ARRAY_INSERT.'); + } + + return state; } -export const defaultMutationResultReducers = { +export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { 'ARRAY_INSERT': mutationResultArrayInsertReducer, }; + +export type MutationResultReducerArgs = { + state: NormalizedCache; + action: MutationArrayInsertAction; + result: GraphQLResult; + variables: any; + fragmentMap: FragmentMap; + selectionSet: SelectionSet; + config: ApolloReducerConfig; +} + +export type MutationResultReducerMap = { + [type: string]: MutationResultReducer; +} + +export type MutationResultReducer = (args: MutationResultReducerArgs) => NormalizedCache; diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts index e3683137f6c..7279b8d8a8f 100644 --- a/src/data/scopeQuery.ts +++ b/src/data/scopeQuery.ts @@ -13,9 +13,22 @@ import { resultKeyNameFromField, } from './storeUtils'; -import cloneDeep = require('lodash.clonedeep'); import isNumber = require('lodash.isnumber'); +export function scopeJSONToResultPath({ + json, + path, +}: { + json: any, + path: (string | number)[], +}) { + let current = json; + path.forEach((pathSegment) => { + current = current[pathSegment]; + }); + return current; +} + export function scopeSelectionSetToResultPath({ selectionSet, fragmentMap, diff --git a/src/data/store.ts b/src/data/store.ts index fa2079edf09..43a2c1024e9 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -33,6 +33,7 @@ import { import { defaultMutationResultReducers, + MutationResultReducerArgs, } from './mutationResultReducers'; import { @@ -50,17 +51,6 @@ export interface StoreObject { export type StoreValue = number | string | string[]; -export type MutationResultReducerMap = { - [type: string]: MutationResultReducer; -} - -export type MutationResultReducer = ( - state: NormalizedCache, - action: MutationApplyResultAction, - result: GraphQLResult, - mutation: Document -) => NormalizedCache; - export function data( previousState: NormalizedCache = {}, action: ApolloAction, @@ -119,12 +109,18 @@ export function data( if (action.applyResult) { action.applyResult.forEach((applyResultAction) => { + const args: MutationResultReducerArgs = { + state: newState, + action: applyResultAction, + result: action.result, + variables: queryStoreValue.variables, + fragmentMap: queryStoreValue.fragmentMap, + selectionSet: queryStoreValue.mutation.selectionSet, + config, + }; + if (defaultMutationResultReducers[applyResultAction.type]) { - newState = defaultMutationResultReducers[applyResultAction.type]( - newState, - applyResultAction, - action.result - ); + newState = defaultMutationResultReducers[applyResultAction.type](args); } else { throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); } diff --git a/src/store.ts b/src/store.ts index 3bd84278c91..315c3d6de21 100644 --- a/src/store.ts +++ b/src/store.ts @@ -30,7 +30,7 @@ import { import { MutationResultReducerMap, -} from './data/store'; +} from './data/mutationResultReducers'; export interface Store { data: NormalizedCache; diff --git a/test/mutations.ts b/test/mutations.ts index 5bc3b88b99e..20f4a12f013 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -161,7 +161,7 @@ describe('mutation results', () => { applyResult: [{ type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], - storePath: [ '5', 'todos' ], + storePath: [ 'TodoList5', 'todos' ], where: 'PREPEND', }] }); @@ -174,7 +174,7 @@ describe('mutation results', () => { 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'); + assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); }); }); }); From 696ecadbc57ce7aea034973ff8009b94841fa124 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 17:19:39 -0700 Subject: [PATCH 12/36] Implement APPEND --- src/data/mutationResultReducers.ts | 4 +-- test/mutations.ts | 57 ++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/data/mutationResultReducers.ts b/src/data/mutationResultReducers.ts index 46b6fb59b07..f8dfd030c89 100644 --- a/src/data/mutationResultReducers.ts +++ b/src/data/mutationResultReducers.ts @@ -73,8 +73,6 @@ function mutationResultArrayInsertReducer({ fragmentMap, }); - console.log(JSON.stringify(state, null, 2)); - // Step 3: insert dataId reference into storePath array const array = scopeJSONToResultPath({ json: state, @@ -83,6 +81,8 @@ function mutationResultArrayInsertReducer({ if (where === 'PREPEND') { array.unshift(dataId); + } else if (where === 'APPEND') { + array.push(dataId); } else { throw new Error('Unsupported "where" option to ARRAY_INSERT.'); } diff --git a/test/mutations.ts b/test/mutations.ts index 20f4a12f013..0f5af975cb9 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -125,7 +125,7 @@ describe('mutation results', () => { }); describe('ARRAY_INSERT', () => { - it('correctly integrates a basic object', () => { + it('correctly integrates a basic object at the beginning', () => { const mutation = gql` mutation createTodo { # skipping arguments in the test since they don't matter @@ -163,7 +163,7 @@ describe('mutation results', () => { resultPath: [ 'createTodo' ], storePath: [ 'TodoList5', 'todos' ], where: 'PREPEND', - }] + }], }); }) .then(() => { @@ -177,5 +177,58 @@ describe('mutation results', () => { assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); }); }); + + it('correctly integrates a basic object at the end', () => { + 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: { + __typename: 'Todo', + id: '99', + text: 'This one was created with a mutation.', + completed: true, + } + } + }; + + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [{ + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'APPEND', + }], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 4); + + // Since we used `APPEND` it should be at the end + assert.equal(newResult.data.todoList.todos[3].text, 'This one was created with a mutation.'); + }); + }); }); }); From de3fe00b91cd06638e26bd0bd70576ae2557bf09 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 17:26:27 -0700 Subject: [PATCH 13/36] Refactor, and add another test --- test/mutations.ts | 102 +++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 46 deletions(-) diff --git a/test/mutations.ts b/test/mutations.ts index 0f5af975cb9..7bc32dce97f 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -125,32 +125,32 @@ describe('mutation results', () => { }); describe('ARRAY_INSERT', () => { - it('correctly integrates a basic object at the beginning', () => { - const mutation = gql` - mutation createTodo { - # skipping arguments in the test since they don't matter - createTodo { - id - text - completed - __typename - } + 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: { - __typename: 'Todo', - id: '99', - text: 'This one was created with a mutation.', - completed: true, - } + const mutationResult = { + data: { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + id: '99', + text: 'This one was created with a mutation.', + completed: true, } - }; + } + }; + it('correctly integrates a basic object at the beginning', () => { return setup({ request: { query: mutation }, result: mutationResult, @@ -179,31 +179,6 @@ describe('mutation results', () => { }); it('correctly integrates a basic object at the end', () => { - 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: { - __typename: 'Todo', - id: '99', - text: 'This one was created with a mutation.', - completed: true, - } - } - }; - return setup({ request: { query: mutation }, result: mutationResult, @@ -230,5 +205,40 @@ describe('mutation results', () => { assert.equal(newResult.data.todoList.todos[3].text, 'This one was created with a mutation.'); }); }); + + it('accepts two operations', () => { + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [{ + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'PREPEND', + }, { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'APPEND', + }], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // There should be one more todo item than before + assert.equal(newResult.data.todoList.todos.length, 5); + + // There will be two copies + assert.equal(newResult.data.todoList.todos[0].text, 'This one was created with a mutation.'); + + assert.equal(newResult.data.todoList.todos[4].text, 'This one was created with a mutation.'); + }); + }); }); }); From db038206baa5e5231c426a90b8813d7c3b9b5625 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:05:40 -0700 Subject: [PATCH 14/36] Start on delete --- src/QueryManager.ts | 2 +- src/actions.ts | 2 +- src/data/mutationResultActions.ts | 13 ----- ...onResultReducers.ts => mutationResults.ts} | 48 +++++++++++++++--- src/data/store.ts | 5 +- src/index.ts | 2 +- src/store.ts | 2 +- test/mutations.ts | 49 +++++++++++++++++++ 8 files changed, 96 insertions(+), 27 deletions(-) delete mode 100644 src/data/mutationResultActions.ts rename src/data/{mutationResultReducers.ts => mutationResults.ts} (74%) diff --git a/src/QueryManager.ts b/src/QueryManager.ts index 90c04d6395f..c27a594f2a3 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -49,7 +49,7 @@ import { import { MutationApplyResultAction, -} from './data/mutationResultActions'; +} from './data/mutationResults'; import { queryDocument, diff --git a/src/actions.ts b/src/actions.ts index a8c88790b2d..9d8821cae03 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -8,7 +8,7 @@ import { import { MutationApplyResultAction, -} from './data/mutationResultActions'; +} from './data/mutationResults'; import { FragmentMap } from './queries/getFromAST'; diff --git a/src/data/mutationResultActions.ts b/src/data/mutationResultActions.ts deleted file mode 100644 index d338280d094..00000000000 --- a/src/data/mutationResultActions.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type MutationApplyResultAction = - MutationArrayInsertAction; - -export type MutationArrayInsertAction = { - type: 'ARRAY_INSERT'; - resultPath: string[]; - storePath: string[]; - where: ArrayInsertWhere; -} - -export type ArrayInsertWhere = - 'PREPEND' | - 'APPEND'; diff --git a/src/data/mutationResultReducers.ts b/src/data/mutationResults.ts similarity index 74% rename from src/data/mutationResultReducers.ts rename to src/data/mutationResults.ts index f8dfd030c89..f3c6e78c2ff 100644 --- a/src/data/mutationResultReducers.ts +++ b/src/data/mutationResults.ts @@ -1,7 +1,3 @@ -import { - MutationArrayInsertAction, -} from './mutationResultActions'; - import { NormalizedCache, } from './store'; @@ -11,6 +7,8 @@ import { SelectionSet, } from 'graphql'; +import forOwn = require('lodash.forown'); + import { FragmentMap, } from '../queries/getFromAST'; @@ -28,6 +26,27 @@ import { writeSelectionSetToStore, } from './writeToStore'; +export type MutationApplyResultAction = + MutationArrayInsertAction | + MutationDeleteAction; + +export type MutationArrayInsertAction = { + type: 'ARRAY_INSERT'; + resultPath: string[]; + storePath: string[]; + where: ArrayInsertWhere; +} + +export type MutationDeleteAction = { + type: 'DELETE'; + dataId: string; +} + +export type ArrayInsertWhere = + 'PREPEND' | + 'APPEND'; + + function mutationResultArrayInsertReducer({ state, action, @@ -41,7 +60,7 @@ function mutationResultArrayInsertReducer({ resultPath, storePath, where, - } = action; + } = action as MutationArrayInsertAction; // Step 1: get selection set and result for resultPath // This might actually be a relatively complex operation, on the level of @@ -90,13 +109,30 @@ function mutationResultArrayInsertReducer({ return state; } +function mutationResultDeleteReducer({ + action, + state, +}: MutationResultReducerArgs): NormalizedCache { + const { + dataId, + } = action as MutationDeleteAction; + + delete state[dataId]; + + // Now we need to go through the whole store and remove all references + + + return state; +} + export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { 'ARRAY_INSERT': mutationResultArrayInsertReducer, + 'DELETE': mutationResultDeleteReducer, }; export type MutationResultReducerArgs = { state: NormalizedCache; - action: MutationArrayInsertAction; + action: MutationApplyResultAction; result: GraphQLResult; variables: any; fragmentMap: FragmentMap; diff --git a/src/data/store.ts b/src/data/store.ts index 43a2c1024e9..3dbb8dc2407 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -29,12 +29,9 @@ import { import { MutationApplyResultAction, -} from './mutationResultActions'; - -import { defaultMutationResultReducers, MutationResultReducerArgs, -} from './mutationResultReducers'; +} from './mutationResults'; import { GraphQLResult, diff --git a/src/index.ts b/src/index.ts index 2c9eb0c3c8b..a796cdc6692 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { import { MutationApplyResultAction, -} from './data/mutationResultActions'; +} from './data/mutationResults'; import isUndefined = require('lodash.isundefined'); diff --git a/src/store.ts b/src/store.ts index 315c3d6de21..2a1ebef868d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -30,7 +30,7 @@ import { import { MutationResultReducerMap, -} from './data/mutationResultReducers'; +} from './data/mutationResults'; export interface Store { data: NormalizedCache; diff --git a/test/mutations.ts b/test/mutations.ts index 7bc32dce97f..e8e22707cb6 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -241,4 +241,53 @@ describe('mutation results', () => { }); }); }); + + describe('DELETE', () => { + const mutation = gql` + mutation deleteTodo { + # skipping arguments in the test since they don't matter + deleteTodo { + id + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + deleteTodo: { + __typename: 'Todo', + id: '3', + } + } + }; + + it('deletes an object from an array', () => { + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [{ + type: 'DELETE', + dataId: 'Todo3', + }], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // 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.'); + }); + }); + }); }); From 77f2fc4e160f072b9366dbc41088e29211d8ab44 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:20:28 -0700 Subject: [PATCH 15/36] Delete object from array --- package.json | 1 + src/data/mutationResults.ts | 40 +++++++++++++++++++++++++++++++++++-- test/mutations.ts | 5 +---- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 0489f099039..ba71e2b2e62 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lodash.isobject": "^3.0.2", "lodash.isstring": "^4.0.1", "lodash.isundefined": "^3.0.1", + "lodash.mapvalues": "^4.4.0", "redux": "^3.3.1", "symbol-observable": "^0.2.4", "whatwg-fetch": "^1.0.0" diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index f3c6e78c2ff..ab356345aae 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -7,7 +7,8 @@ import { SelectionSet, } from 'graphql'; -import forOwn = require('lodash.forown'); +import mapValues = require('lodash.mapvalues'); +import isArray = require('lodash.isarray'); import { FragmentMap, @@ -120,9 +121,44 @@ function mutationResultDeleteReducer({ delete state[dataId]; // Now we need to go through the whole store and remove all references + const newState = mapValues(state, (storeObj) => { + return removeRefsFromStoreObj(storeObj, dataId); + }); + + return newState; +} +function removeRefsFromStoreObj(storeObj, dataId) { + let affected = false; - return state; + const cleanedObj = mapValues(storeObj, (value, key) => { + if (value === dataId) { + affected = true; + return null; + } + + if (isArray(value)) { + affected = true; + return cleanArray(value, dataId); + } + }); + + if (affected) { + // Maintain === for unchanged objects + return cleanedObj; + } else { + return storeObj; + } +} + +function cleanArray(arr, dataId) { + if (arr.length && isArray(arr[0])) { + // Handle arbitrarily nested arrays + return arr.map((nestedArray) => cleanArray(nestedArray, dataId)); + } else { + // XXX this will create a new array reference even if no items were removed + return arr.filter((item) => item !== dataId); + } } export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { diff --git a/test/mutations.ts b/test/mutations.ts index e8e22707cb6..b0f3585f71e 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -283,10 +283,7 @@ describe('mutation results', () => { }) .then((newResult: any) => { // 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.'); + assert.equal(newResult.data.todoList.todos.length, 2); }); }); }); From 12681805cb3e5a0763648c438935d29a26d58c81 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:29:06 -0700 Subject: [PATCH 16/36] Add comment --- src/data/mutationResults.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index ab356345aae..10dc1ee6458 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -118,6 +118,7 @@ function mutationResultDeleteReducer({ dataId, } = action as MutationDeleteAction; + // Delete the object delete state[dataId]; // Now we need to go through the whole store and remove all references From 61d4c2ef58976dc83a79c06965805b475567a75f Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:30:51 -0700 Subject: [PATCH 17/36] Add ref to tweet --- src/data/mutationResults.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 10dc1ee6458..ca3db6cc8a2 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -158,6 +158,7 @@ function cleanArray(arr, dataId) { return arr.map((nestedArray) => cleanArray(nestedArray, dataId)); } else { // XXX this will create a new array reference even if no items were removed + // switch to this: https://twitter.com/leeb/status/747601132080377856 return arr.filter((item) => item !== dataId); } } From 2ac9b670f60a3335315ec20350ff6580a7745052 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:42:54 -0700 Subject: [PATCH 18/36] Add warning --- src/data/mutationResults.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index ca3db6cc8a2..7cf13a00681 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -43,6 +43,12 @@ export type MutationDeleteAction = { dataId: string; } +export type MutationArrayDeleteAction = { + type: 'ARRAY_DELETE'; + storePath: string[]; + dataId: string; +} + export type ArrayInsertWhere = 'PREPEND' | 'APPEND'; @@ -94,6 +100,7 @@ function mutationResultArrayInsertReducer({ }); // Step 3: insert dataId reference into storePath array + // XXX this is actually mutating the array! Do not merge const array = scopeJSONToResultPath({ json: state, path: storePath, From d5ac1746dd10d9cd8e6dead67c197e23f1f94360 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:44:30 -0700 Subject: [PATCH 19/36] Improve test --- test/mutations.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/mutations.ts b/test/mutations.ts index b0f3585f71e..a1d9af1f26c 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -282,8 +282,11 @@ describe('mutation results', () => { return client.query({ query }); }) .then((newResult: any) => { - // There should be one more todo item than before + // There should be one fewer todo item than before assert.equal(newResult.data.todoList.todos.length, 2); + + // The item shouldn't be in the store anymore + assert.notProperty(client.queryManager.getApolloState().data, 'Todo3'); }); }); }); From c851979937fa635eb24dd74223c667f040cad001 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 18:54:18 -0700 Subject: [PATCH 20/36] Add ARRAY_DELETE --- src/data/mutationResults.ts | 27 +++++++++++++++++++ test/mutations.ts | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 7cf13a00681..b71220b254b 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -9,6 +9,8 @@ import { import mapValues = require('lodash.mapvalues'); import isArray = require('lodash.isarray'); +import cloneDeep = require('lodash.clonedeep'); +import assign = require('lodash.assign'); import { FragmentMap, @@ -29,6 +31,7 @@ import { export type MutationApplyResultAction = MutationArrayInsertAction | + MutationArrayDeleteAction | MutationDeleteAction; export type MutationArrayInsertAction = { @@ -170,9 +173,33 @@ function cleanArray(arr, dataId) { } } +function mutationResultArrayDeleteReducer({ + action, + state, +}: MutationResultReducerArgs): NormalizedCache { + const { + dataId, + storePath, + } = action as MutationArrayDeleteAction; + + const dataIdOfObj = storePath.shift(); + const clonedObj = cloneDeep(state[dataIdOfObj]); + const array = scopeJSONToResultPath({ + json: clonedObj, + path: storePath, + }); + + array.splice(array.indexOf(dataId), 1); + + return assign(state, { + [dataIdOfObj]: clonedObj, + }) as NormalizedCache; +} + export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { 'ARRAY_INSERT': mutationResultArrayInsertReducer, 'DELETE': mutationResultDeleteReducer, + 'ARRAY_DELETE': mutationResultArrayDeleteReducer, }; export type MutationResultReducerArgs = { diff --git a/test/mutations.ts b/test/mutations.ts index a1d9af1f26c..e170d471462 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -264,7 +264,7 @@ describe('mutation results', () => { } }; - it('deletes an object from an array', () => { + it('deletes object from array and store', () => { return setup({ request: { query: mutation }, result: mutationResult, @@ -290,4 +290,54 @@ describe('mutation results', () => { }); }); }); + + describe('ARRAY_DELETE', () => { + const mutation = gql` + mutation removeTodo { + # skipping arguments in the test since they don't matter + removeTodo { + id + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + removeTodo: { + __typename: 'Todo', + id: '3', + } + } + }; + + it('deletes an object from array but not store', () => { + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [{ + type: 'ARRAY_DELETE', + dataId: 'Todo3', + storePath: ['TodoList5', 'todos'], + }], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // There should be one fewer todo item than before + assert.equal(newResult.data.todoList.todos.length, 2); + + // The item is still in the store + assert.property(client.queryManager.getApolloState().data, 'Todo3'); + }); + }); + }); }); From 034b8b7695b73993cc58641476cc85b6db549501 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 20:54:58 -0700 Subject: [PATCH 21/36] Implement and test custom reducers --- src/data/store.ts | 2 ++ src/index.ts | 4 +++ test/mutations.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/data/store.ts b/src/data/store.ts index 3dbb8dc2407..97e914c2267 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -118,6 +118,8 @@ export function data( if (defaultMutationResultReducers[applyResultAction.type]) { newState = defaultMutationResultReducers[applyResultAction.type](args); + } else if (config.mutationResultReducers[applyResultAction.type]) { + newState = config.mutationResultReducers[applyResultAction.type](args); } else { throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); } diff --git a/src/index.ts b/src/index.ts index a796cdc6692..ea65851bbcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import { import { MutationApplyResultAction, + MutationResultReducerMap, } from './data/mutationResults'; import isUndefined = require('lodash.isundefined'); @@ -77,6 +78,7 @@ export default class ApolloClient { shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, + mutationResultReducers = {} as MutationResultReducerMap, }: { networkInterface?: NetworkInterface, reduxRootKey?: string, @@ -86,6 +88,7 @@ export default class ApolloClient { shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number + mutationResultReducers?: MutationResultReducerMap, } = {}) { this.reduxRootKey = reduxRootKey ? reduxRootKey : 'apollo'; this.initialState = initialState ? initialState : {}; @@ -100,6 +103,7 @@ export default class ApolloClient { this.reducerConfig = { dataIdFromObject, + mutationResultReducers, }; } diff --git a/test/mutations.ts b/test/mutations.ts index e170d471462..a38c49f99f6 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -1,6 +1,10 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; import ApolloClient, { addTypename } from '../src'; +import { MutationResultReducerArgs, MutationApplyResultAction } from '../src/data/mutationResults'; +import { NormalizedCache, StoreObject } from '../src/data/store'; + +import assign = require('lodash.assign'); import gql from 'graphql-tag'; @@ -54,6 +58,27 @@ describe('mutation results', () => { let client: ApolloClient; let networkInterface; + type CustomMutationResultAction = { + type: 'CUSTOM_MUTATION_RESULT', + dataId: string, + field: string, + value: any, + } + + // This is an example of a basic mutation reducer that just sets a field in the store + function customMutationReducer({ + state, + action, + }: MutationResultReducerArgs): NormalizedCache { + const customAction = action as any as CustomMutationResultAction; + + state[customAction.dataId] = assign({}, state[customAction.dataId], { + [customAction.field]: customAction.value, + }) as StoreObject; + + return state; + } + function setup(...mockedResponses) { networkInterface = mockNetworkInterface({ request: { query }, @@ -62,6 +87,7 @@ describe('mutation results', () => { client = new ApolloClient({ networkInterface, + // XXX right now this isn't compatible with our mocking // strategy... // FIX BEFORE PR MERGE @@ -72,6 +98,10 @@ describe('mutation results', () => { } return null; }, + + mutationResultReducers: { + 'CUSTOM_MUTATION_RESULT': customMutationReducer, + }, }); return client.query({ @@ -105,8 +135,8 @@ describe('mutation results', () => { __typename: 'Todo', id: '3', completed: true, - } - } + }, + }, }; return setup({ @@ -309,8 +339,8 @@ describe('mutation results', () => { removeTodo: { __typename: 'Todo', id: '3', - } - } + }, + }, }; it('deletes an object from array but not store', () => { @@ -340,4 +370,54 @@ describe('mutation results', () => { }); }); }); + + describe('CUSTOM_MUTATION_RESULT', () => { + const mutation = gql` + mutation setField { + # skipping arguments in the test since they don't matter + setSomething { + aValue + __typename + } + __typename + } + `; + + const mutationResult = { + data: { + __typename: 'Mutation', + setSomething: { + __typename: 'Value', + aValue: 'rainbow', + }, + }, + }; + + it('runs the custom reducer', () => { + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + applyResult: [ + { + type: 'CUSTOM_MUTATION_RESULT', + dataId: 'Todo3', + field: 'text', + value: 'this is the new text', + } as any as MutationApplyResultAction, + ], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // Our custom reducer has indeed modified the state! + assert.equal(newResult.data.todoList.todos[0].text, 'this is the new text'); + }); + }); + }); }); From 3ec8ec314aa1de05e8d7f5bc721bf941805c4cd6 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 21:11:59 -0700 Subject: [PATCH 22/36] Bump size limit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2dd31808dd..b2e957bc88e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The Apollo Client can easily be dropped into any JavaScript frontend where you want to use data from a GraphQL server. -It's simple to use, and very small (less than 32kb), while having a lot of useful features around caching, polling, and refetching. +It's simple to use, and very small (less than 33kb), while having a lot of useful features around caching, polling, and refetching. ## Installing From 54b6c33a633113d366c29057bac7f41f215d4bb3 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 21:32:08 -0700 Subject: [PATCH 23/36] Fix lint errors --- src/data/store.ts | 5 --- test/mutations.ts | 84 ++++++++++++++++++++++++++--------------------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/data/store.ts b/src/data/store.ts index 97e914c2267..1e440ef0a42 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -28,15 +28,10 @@ import { } from './storeUtils'; import { - MutationApplyResultAction, defaultMutationResultReducers, MutationResultReducerArgs, } from './mutationResults'; -import { - GraphQLResult, -} from 'graphql'; - export interface NormalizedCache { [dataId: string]: StoreObject; } diff --git a/test/mutations.ts b/test/mutations.ts index a38c49f99f6..15f7defcc9c 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; -import ApolloClient, { addTypename } from '../src'; +import ApolloClient from '../src'; import { MutationResultReducerArgs, MutationApplyResultAction } from '../src/data/mutationResults'; import { NormalizedCache, StoreObject } from '../src/data/store'; @@ -176,8 +176,8 @@ describe('mutation results', () => { id: '99', text: 'This one was created with a mutation.', completed: true, - } - } + }, + }, }; it('correctly integrates a basic object at the beginning', () => { @@ -188,12 +188,14 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [{ - type: 'ARRAY_INSERT', - resultPath: [ 'createTodo' ], - storePath: [ 'TodoList5', 'todos' ], - where: 'PREPEND', - }], + applyResult: [ + { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'PREPEND', + }, + ], }); }) .then(() => { @@ -216,12 +218,14 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [{ - type: 'ARRAY_INSERT', - resultPath: [ 'createTodo' ], - storePath: [ 'TodoList5', 'todos' ], - where: 'APPEND', - }], + applyResult: [ + { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'APPEND', + }, + ], }); }) .then(() => { @@ -244,17 +248,19 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [{ - type: 'ARRAY_INSERT', - resultPath: [ 'createTodo' ], - storePath: [ 'TodoList5', 'todos' ], - where: 'PREPEND', - }, { - type: 'ARRAY_INSERT', - resultPath: [ 'createTodo' ], - storePath: [ 'TodoList5', 'todos' ], - where: 'APPEND', - }], + applyResult: [ + { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'PREPEND', + }, { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList5', 'todos' ], + where: 'APPEND', + }, + ], }); }) .then(() => { @@ -290,8 +296,8 @@ describe('mutation results', () => { deleteTodo: { __typename: 'Todo', id: '3', - } - } + }, + }, }; it('deletes object from array and store', () => { @@ -302,10 +308,12 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [{ - type: 'DELETE', - dataId: 'Todo3', - }], + applyResult: [ + { + type: 'DELETE', + dataId: 'Todo3', + }, + ], }); }) .then(() => { @@ -351,11 +359,13 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [{ - type: 'ARRAY_DELETE', - dataId: 'Todo3', - storePath: ['TodoList5', 'todos'], - }], + applyResult: [ + { + type: 'ARRAY_DELETE', + dataId: 'Todo3', + storePath: ['TodoList5', 'todos'], + }, + ], }); }) .then(() => { From 85986152fc8c04b756e47520be3d8f29bed1dee1 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 22:13:47 -0700 Subject: [PATCH 24/36] Add transformer --- test/mutations.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/mutations.ts b/test/mutations.ts index 15f7defcc9c..654f6d81788 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -1,6 +1,6 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; -import ApolloClient from '../src'; +import ApolloClient, { addTypename } from '../src'; import { MutationResultReducerArgs, MutationApplyResultAction } from '../src/data/mutationResults'; import { NormalizedCache, StoreObject } from '../src/data/store'; @@ -11,17 +11,17 @@ import gql from 'graphql-tag'; describe('mutation results', () => { const query = gql` query todoList { + __typename todoList(id: 5) { + __typename id todos { + __typename id text completed - __typename } - __typename } - __typename } `; @@ -87,6 +87,7 @@ describe('mutation results', () => { client = new ApolloClient({ networkInterface, + queryTransformer: addTypename, // XXX right now this isn't compatible with our mocking // strategy... From 4f989bacd2f63290a580bc28ac431d9c7b4fa0d3 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 22:27:37 -0700 Subject: [PATCH 25/36] Expose IdGetter --- src/data/mutationResults.ts | 10 ++++------ src/index.ts | 3 +++ test/mutations.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index b71220b254b..319814be664 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -36,8 +36,8 @@ export type MutationApplyResultAction = export type MutationArrayInsertAction = { type: 'ARRAY_INSERT'; - resultPath: string[]; - storePath: string[]; + resultPath: (string|number)[]; + storePath: StorePath; where: ArrayInsertWhere; } @@ -48,7 +48,7 @@ export type MutationDeleteAction = { export type MutationArrayDeleteAction = { type: 'ARRAY_DELETE'; - storePath: string[]; + storePath: StorePath; dataId: string; } @@ -56,6 +56,7 @@ export type ArrayInsertWhere = 'PREPEND' | 'APPEND'; +export type StorePath = (string|number)[]; function mutationResultArrayInsertReducer({ state, @@ -73,9 +74,6 @@ function mutationResultArrayInsertReducer({ } = action as MutationArrayInsertAction; // Step 1: get selection set and result for resultPath - // This might actually be a relatively complex operation, on the level of - // writing a query... perhaps we can factor that out - // Note: this is also necessary for incorporating defer results..! const scopedSelectionSet = scopeSelectionSetToResultPath({ selectionSet, fragmentMap, diff --git a/src/index.ts b/src/index.ts index ea65851bbcc..602ee1b00d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export default class ApolloClient { public queryTransformer: QueryTransformer; public shouldBatch: boolean; public shouldForceFetch: boolean; + public dataId: IdGetter; constructor({ networkInterface, @@ -97,6 +98,8 @@ export default class ApolloClient { this.queryTransformer = queryTransformer; this.shouldBatch = shouldBatch; this.shouldForceFetch = !(ssrMode || ssrForceFetchDelay > 0); + this.dataId = dataIdFromObject; + if (ssrForceFetchDelay) { setTimeout(() => this.shouldForceFetch = true, ssrForceFetchDelay); } diff --git a/test/mutations.ts b/test/mutations.ts index 654f6d81788..a99729f3766 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -88,11 +88,6 @@ describe('mutation results', () => { client = new ApolloClient({ networkInterface, queryTransformer: addTypename, - - // XXX right now this isn't compatible with our mocking - // strategy... - // FIX BEFORE PR MERGE - // queryTransformer: addTypename, dataIdFromObject: (obj: any) => { if (obj.id && obj.__typename) { return obj.__typename + obj.id; @@ -187,13 +182,18 @@ describe('mutation results', () => { result: mutationResult, }) .then(() => { + const dataId = client.dataId({ + __typename: 'TodoList', + id: '5', + }); + return client.mutate({ mutation, applyResult: [ { type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], - storePath: [ 'TodoList5', 'todos' ], + storePath: [ dataId, 'todos' ], where: 'PREPEND', }, ], From a7b4e81d7337a20eb045681b4849f72e061ae91e Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 22:35:20 -0700 Subject: [PATCH 26/36] Add comments --- src/data/scopeQuery.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts index 7279b8d8a8f..26954e7dc0e 100644 --- a/src/data/scopeQuery.ts +++ b/src/data/scopeQuery.ts @@ -15,6 +15,8 @@ import { import isNumber = require('lodash.isnumber'); +// This function takes a json blob and a path array, and returns the object at that path in the JSON +// blob. export function scopeJSONToResultPath({ json, path, @@ -29,6 +31,9 @@ export function scopeJSONToResultPath({ return current; } +// Using the same path format as scopeJSONToResultPath, this applies the same operation to a GraphQL +// query. You get the selection set of the query at the path specified. It also reaches into +// fragments. export function scopeSelectionSetToResultPath({ selectionSet, fragmentMap, @@ -51,6 +56,7 @@ export function scopeSelectionSetToResultPath({ return currSelSet; } +// Helper function for scopeSelectionSetToResultPath function followOnePathSegment( currSelSet: SelectionSet, pathSegment: string, @@ -70,6 +76,7 @@ function followOnePathSegment( return matchingFields[0].selectionSet; } +// Helper function for followOnePathSegment function getMatchingFields( currSelSet: SelectionSet, pathSegment: string, From 8ad19991a244796e655be730fabe00377e3ac9f6 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 22:46:44 -0700 Subject: [PATCH 27/36] Fix some bugs --- src/data/mutationResults.ts | 66 ++++++++++++++++++++++++------------- src/data/scopeQuery.ts | 7 ++-- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 319814be664..6d10bdd64e0 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -19,6 +19,7 @@ import { import { scopeSelectionSetToResultPath, scopeJSONToResultPath, + StorePath, } from './scopeQuery'; import { @@ -29,6 +30,8 @@ import { writeSelectionSetToStore, } from './writeToStore'; +// Mutation result action types, these can be used in the `applyResult` argument to client.mutate + export type MutationApplyResultAction = MutationArrayInsertAction | MutationArrayDeleteAction | @@ -36,7 +39,7 @@ export type MutationApplyResultAction = export type MutationArrayInsertAction = { type: 'ARRAY_INSERT'; - resultPath: (string|number)[]; + resultPath: StorePath; storePath: StorePath; where: ArrayInsertWhere; } @@ -56,8 +59,25 @@ export type ArrayInsertWhere = 'PREPEND' | 'APPEND'; -export type StorePath = (string|number)[]; +// These are the generic arguments passed into the mutation result reducers +// The `action` field is specific to each reducer +export type MutationResultReducerArgs = { + state: NormalizedCache; + action: MutationApplyResultAction; + result: GraphQLResult; + variables: any; + fragmentMap: FragmentMap; + selectionSet: SelectionSet; + config: ApolloReducerConfig; +} + +export type MutationResultReducerMap = { + [type: string]: MutationResultReducer; +} + +export type MutationResultReducer = (args: MutationResultReducerArgs) => NormalizedCache; +// Reducer for ARRAY_INSERT action function mutationResultArrayInsertReducer({ state, action, @@ -86,8 +106,7 @@ function mutationResultArrayInsertReducer({ }); // OK, now we need to get a dataID to pass to writeSelectionSetToStore - // XXX generate dataID here! - const dataId = config.dataIdFromObject(scopedResult) || 'xxx'; + const dataId = config.dataIdFromObject(scopedResult) || generateMutationResultDataId(); // Step 2: insert object into store with writeSelectionSet state = writeSelectionSetToStore({ @@ -101,9 +120,10 @@ function mutationResultArrayInsertReducer({ }); // Step 3: insert dataId reference into storePath array - // XXX this is actually mutating the array! Do not merge + const dataIdOfObj = storePath.shift(); + const clonedObj = cloneDeep(state[dataIdOfObj]); const array = scopeJSONToResultPath({ - json: state, + json: clonedObj, path: storePath, }); @@ -115,9 +135,22 @@ function mutationResultArrayInsertReducer({ throw new Error('Unsupported "where" option to ARRAY_INSERT.'); } - return state; + return assign(state, { + [dataIdOfObj]: clonedObj, + }) as NormalizedCache; +} + +// Helper for ARRAY_INSERT. +// When writing query results to the store, we generate IDs based on their path in the query. Here, +// we don't have access to such uniquely identifying information, so the best we can do is a +// sequential ID. +let currId = 0; +function generateMutationResultDataId() { + currId++; + return `ARRAY_INSERT-gen-id-${currId}`; } +// Reducer for 'DELETE' action function mutationResultDeleteReducer({ action, state, @@ -171,6 +204,7 @@ function cleanArray(arr, dataId) { } } +// Reducer for 'ARRAY_DELETE' action function mutationResultArrayDeleteReducer({ action, state, @@ -194,24 +228,10 @@ function mutationResultArrayDeleteReducer({ }) as NormalizedCache; } +// Combines all of the default reducers into a map based on the action type they accept +// The action type is used to pick the right reducer when evaluating the result of the mutation export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { 'ARRAY_INSERT': mutationResultArrayInsertReducer, 'DELETE': mutationResultDeleteReducer, 'ARRAY_DELETE': mutationResultArrayDeleteReducer, }; - -export type MutationResultReducerArgs = { - state: NormalizedCache; - action: MutationApplyResultAction; - result: GraphQLResult; - variables: any; - fragmentMap: FragmentMap; - selectionSet: SelectionSet; - config: ApolloReducerConfig; -} - -export type MutationResultReducerMap = { - [type: string]: MutationResultReducer; -} - -export type MutationResultReducer = (args: MutationResultReducerArgs) => NormalizedCache; diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts index 26954e7dc0e..a993a95672a 100644 --- a/src/data/scopeQuery.ts +++ b/src/data/scopeQuery.ts @@ -15,6 +15,9 @@ import { import isNumber = require('lodash.isnumber'); +// The type of a path +export type StorePath = (string|number)[]; + // This function takes a json blob and a path array, and returns the object at that path in the JSON // blob. export function scopeJSONToResultPath({ @@ -22,7 +25,7 @@ export function scopeJSONToResultPath({ path, }: { json: any, - path: (string | number)[], + path: StorePath, }) { let current = json; path.forEach((pathSegment) => { @@ -42,7 +45,7 @@ export function scopeSelectionSetToResultPath({ selectionSet: SelectionSet, fragmentMap?: FragmentMap, // Path segment is string for objects, number for arrays - path: (string | number)[], + path: StorePath, }): SelectionSet { let currSelSet = selectionSet; From 80c2df166a7892a8a7f6d8298be3d62fcf0b1a66 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Mon, 27 Jun 2016 23:28:25 -0700 Subject: [PATCH 28/36] Add changelog entry --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e070defd43..da4ae9fad2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ Expect active development and potentially significant breaking changes in the `0 - Fix unintentional breaking change where `apollo-client/gql` import stopped working. [Issue #327](https://github.com/apollostack/apollo-client/issues/327) +- **Add mutation result handling to Apollo Client.** This is done by passing an `applyResult` option to +`client.mutate`, with an array of "Mutation Result Actions". You can attach any number of result +actions to each mutation. These result actions are attached to the `MUTATION_RESULT` redux action +that is dispatched when the query result arrives from the store, and are handled by special +"Mutation Result Reducers". These are special because they get a whole bunch of GraphQL-specific +information in the arguments, and are all called synchronously when the result of a mutation +arrives. In this version, Apollo Client ships with a set of default mutation result actions/reducers +including `ARRAY_INSERT`, `DELETE`, and `ARRAY_DELETE`, but you can add any custom ones you want +by passing the new `mutationResultReducers` option to the `ApolloClient` constructor. The previous +default functionality of merging all mutation results into the store is preserved. +[PR #320](https://github.com/apollostack/apollo-client/pull/320) +[Read the design in depth in Issue #317](https://github.com/apollostack/apollo-client/issues/317) + ### v0.3.21 - Move out GraphQL query parsing into a new package [`graphql-tag`](https://github.com/apollostack/graphql-tag) with a backcompat shim for `apollo-client/gql`. [Issue #312](https://github.com/apollostack/apollo-client/issues/312) [PR #313](https://github.com/apollostack/apollo-client/pull/313) From 0362626ae3734d774b1b8460b8d6414cb156aedb Mon Sep 17 00:00:00 2001 From: abhiaiyer91 Date: Mon, 27 Jun 2016 23:51:34 -0700 Subject: [PATCH 29/36] change arguments around --- src/data/mutationResults.ts | 12 ++++-------- src/data/store.ts | 5 ++--- test/mutations.ts | 3 +-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 6d10bdd64e0..5d046db0944 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -62,7 +62,6 @@ export type ArrayInsertWhere = // These are the generic arguments passed into the mutation result reducers // The `action` field is specific to each reducer export type MutationResultReducerArgs = { - state: NormalizedCache; action: MutationApplyResultAction; result: GraphQLResult; variables: any; @@ -75,11 +74,10 @@ export type MutationResultReducerMap = { [type: string]: MutationResultReducer; } -export type MutationResultReducer = (args: MutationResultReducerArgs) => NormalizedCache; +export type MutationResultReducer = (state: NormalizedCache, args: MutationResultReducerArgs) => NormalizedCache; // Reducer for ARRAY_INSERT action -function mutationResultArrayInsertReducer({ - state, +function mutationResultArrayInsertReducer(state: NormalizedCache, { action, result, variables, @@ -151,9 +149,8 @@ function generateMutationResultDataId() { } // Reducer for 'DELETE' action -function mutationResultDeleteReducer({ +function mutationResultDeleteReducer(state: NormalizedCache, { action, - state, }: MutationResultReducerArgs): NormalizedCache { const { dataId, @@ -205,9 +202,8 @@ function cleanArray(arr, dataId) { } // Reducer for 'ARRAY_DELETE' action -function mutationResultArrayDeleteReducer({ +function mutationResultArrayDeleteReducer(state: NormalizedCache, { action, - state, }: MutationResultReducerArgs): NormalizedCache { const { dataId, diff --git a/src/data/store.ts b/src/data/store.ts index 1e440ef0a42..77bca297c7e 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -102,7 +102,6 @@ export function data( if (action.applyResult) { action.applyResult.forEach((applyResultAction) => { const args: MutationResultReducerArgs = { - state: newState, action: applyResultAction, result: action.result, variables: queryStoreValue.variables, @@ -112,9 +111,9 @@ export function data( }; if (defaultMutationResultReducers[applyResultAction.type]) { - newState = defaultMutationResultReducers[applyResultAction.type](args); + newState = defaultMutationResultReducers[applyResultAction.type](newState, args); } else if (config.mutationResultReducers[applyResultAction.type]) { - newState = config.mutationResultReducers[applyResultAction.type](args); + newState = config.mutationResultReducers[applyResultAction.type](newState, args); } else { throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); } diff --git a/test/mutations.ts b/test/mutations.ts index a99729f3766..5f17e333358 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -66,8 +66,7 @@ describe('mutation results', () => { } // This is an example of a basic mutation reducer that just sets a field in the store - function customMutationReducer({ - state, + function customMutationReducer(state: NormalizedCache, { action, }: MutationResultReducerArgs): NormalizedCache { const customAction = action as any as CustomMutationResultAction; From 2eca76d053cfde48d960903317f16ffd0b5a7b43 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 22:08:14 -0700 Subject: [PATCH 30/36] Rename mutation result actions to mutation behaviors --- CHANGELOG.md | 4 +-- src/QueryManager.ts | 8 ++--- src/actions.ts | 4 +-- src/data/mutationResults.ts | 58 ++++++++++++++++++------------------- src/data/store.ts | 22 +++++++------- src/index.ts | 12 ++++---- src/store.ts | 4 +-- test/mutations.ts | 30 +++++++++---------- 8 files changed, 71 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4ae9fad2c..e01203734f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Expect active development and potentially significant breaking changes in the `0 - Fix unintentional breaking change where `apollo-client/gql` import stopped working. [Issue #327](https://github.com/apollostack/apollo-client/issues/327) -- **Add mutation result handling to Apollo Client.** This is done by passing an `applyResult` option to +- **Add mutation result handling to Apollo Client.** This is done by passing an `resultBehaviors` option to `client.mutate`, with an array of "Mutation Result Actions". You can attach any number of result actions to each mutation. These result actions are attached to the `MUTATION_RESULT` redux action that is dispatched when the query result arrives from the store, and are handled by special @@ -19,7 +19,7 @@ that is dispatched when the query result arrives from the store, and are handled information in the arguments, and are all called synchronously when the result of a mutation arrives. In this version, Apollo Client ships with a set of default mutation result actions/reducers including `ARRAY_INSERT`, `DELETE`, and `ARRAY_DELETE`, but you can add any custom ones you want -by passing the new `mutationResultReducers` option to the `ApolloClient` constructor. The previous +by passing the new `mutationBehaviorReducers` option to the `ApolloClient` constructor. The previous default functionality of merging all mutation results into the store is preserved. [PR #320](https://github.com/apollostack/apollo-client/pull/320) [Read the design in depth in Issue #317](https://github.com/apollostack/apollo-client/issues/317) diff --git a/src/QueryManager.ts b/src/QueryManager.ts index c27a594f2a3..8afe714d683 100644 --- a/src/QueryManager.ts +++ b/src/QueryManager.ts @@ -48,7 +48,7 @@ import { } from './data/diffAgainstStore'; import { - MutationApplyResultAction, + MutationBehavior, } from './data/mutationResults'; import { @@ -197,11 +197,11 @@ export class QueryManager { public mutate({ mutation, variables, - applyResult, + resultBehaviors, }: { mutation: Document, variables?: Object, - applyResult?: MutationApplyResultAction[], + resultBehaviors?: MutationBehavior[], }): Promise { const mutationId = this.generateQueryId(); @@ -238,7 +238,7 @@ export class QueryManager { type: 'APOLLO_MUTATION_RESULT', result, mutationId, - applyResult, + resultBehaviors, }); return result; diff --git a/src/actions.ts b/src/actions.ts index 9d8821cae03..ac83e9cd4fc 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -7,7 +7,7 @@ import { } from './queries/store'; import { - MutationApplyResultAction, + MutationBehavior, } from './data/mutationResults'; import { FragmentMap } from './queries/getFromAST'; @@ -89,7 +89,7 @@ export interface MutationResultAction { type: 'APOLLO_MUTATION_RESULT'; result: GraphQLResult; mutationId: string; - applyResult?: MutationApplyResultAction[]; + resultBehaviors?: MutationBehavior[]; } export function isMutationResultAction(action: ApolloAction): action is MutationResultAction { diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 5d046db0944..7707b57722b 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -30,26 +30,26 @@ import { writeSelectionSetToStore, } from './writeToStore'; -// Mutation result action types, these can be used in the `applyResult` argument to client.mutate +// Mutation behavior types, these can be used in the `resultBehaviors` argument to client.mutate -export type MutationApplyResultAction = - MutationArrayInsertAction | - MutationArrayDeleteAction | - MutationDeleteAction; +export type MutationBehavior = + MutationArrayInsertBehavior | + MutationArrayDeleteBehavior | + MutationDeleteBehavior; -export type MutationArrayInsertAction = { +export type MutationArrayInsertBehavior = { type: 'ARRAY_INSERT'; resultPath: StorePath; storePath: StorePath; where: ArrayInsertWhere; } -export type MutationDeleteAction = { +export type MutationDeleteBehavior = { type: 'DELETE'; dataId: string; } -export type MutationArrayDeleteAction = { +export type MutationArrayDeleteBehavior = { type: 'ARRAY_DELETE'; storePath: StorePath; dataId: string; @@ -60,9 +60,9 @@ export type ArrayInsertWhere = 'APPEND'; // These are the generic arguments passed into the mutation result reducers -// The `action` field is specific to each reducer -export type MutationResultReducerArgs = { - action: MutationApplyResultAction; +// The `behavior` field is specific to each reducer +export type MutationBehaviorReducerArgs = { + behavior: MutationBehavior; result: GraphQLResult; variables: any; fragmentMap: FragmentMap; @@ -70,26 +70,26 @@ export type MutationResultReducerArgs = { config: ApolloReducerConfig; } -export type MutationResultReducerMap = { - [type: string]: MutationResultReducer; +export type MutationBehaviorReducerMap = { + [type: string]: MutationBehaviorReducer; } -export type MutationResultReducer = (state: NormalizedCache, args: MutationResultReducerArgs) => NormalizedCache; +export type MutationBehaviorReducer = (state: NormalizedCache, args: MutationBehaviorReducerArgs) => NormalizedCache; -// Reducer for ARRAY_INSERT action +// Reducer for ARRAY_INSERT behavior function mutationResultArrayInsertReducer(state: NormalizedCache, { - action, + behavior, result, variables, fragmentMap, selectionSet, config, -}: MutationResultReducerArgs): NormalizedCache { +}: MutationBehaviorReducerArgs): NormalizedCache { const { resultPath, storePath, where, - } = action as MutationArrayInsertAction; + } = behavior as MutationArrayInsertBehavior; // Step 1: get selection set and result for resultPath const scopedSelectionSet = scopeSelectionSetToResultPath({ @@ -148,13 +148,13 @@ function generateMutationResultDataId() { return `ARRAY_INSERT-gen-id-${currId}`; } -// Reducer for 'DELETE' action +// Reducer for 'DELETE' behavior function mutationResultDeleteReducer(state: NormalizedCache, { - action, -}: MutationResultReducerArgs): NormalizedCache { + behavior, +}: MutationBehaviorReducerArgs): NormalizedCache { const { dataId, - } = action as MutationDeleteAction; + } = behavior as MutationDeleteBehavior; // Delete the object delete state[dataId]; @@ -201,14 +201,14 @@ function cleanArray(arr, dataId) { } } -// Reducer for 'ARRAY_DELETE' action +// Reducer for 'ARRAY_DELETE' behavior function mutationResultArrayDeleteReducer(state: NormalizedCache, { - action, -}: MutationResultReducerArgs): NormalizedCache { + behavior, +}: MutationBehaviorReducerArgs): NormalizedCache { const { dataId, storePath, - } = action as MutationArrayDeleteAction; + } = behavior as MutationArrayDeleteBehavior; const dataIdOfObj = storePath.shift(); const clonedObj = cloneDeep(state[dataIdOfObj]); @@ -224,9 +224,9 @@ function mutationResultArrayDeleteReducer(state: NormalizedCache, { }) as NormalizedCache; } -// Combines all of the default reducers into a map based on the action type they accept -// The action type is used to pick the right reducer when evaluating the result of the mutation -export const defaultMutationResultReducers: { [type: string]: MutationResultReducer } = { +// Combines all of the default reducers into a map based on the behavior type they accept +// The behavior type is used to pick the right reducer when evaluating the result of the mutation +export const defaultMutationBehaviorReducers: { [type: string]: MutationBehaviorReducer } = { 'ARRAY_INSERT': mutationResultArrayInsertReducer, 'DELETE': mutationResultDeleteReducer, 'ARRAY_DELETE': mutationResultArrayDeleteReducer, diff --git a/src/data/store.ts b/src/data/store.ts index 77bca297c7e..0f7bc85ce2f 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -28,8 +28,8 @@ import { } from './storeUtils'; import { - defaultMutationResultReducers, - MutationResultReducerArgs, + defaultMutationBehaviorReducers, + MutationBehaviorReducerArgs, } from './mutationResults'; export interface NormalizedCache { @@ -99,10 +99,10 @@ export function data( fragmentMap: queryStoreValue.fragmentMap, }); - if (action.applyResult) { - action.applyResult.forEach((applyResultAction) => { - const args: MutationResultReducerArgs = { - action: applyResultAction, + if (action.resultBehaviors) { + action.resultBehaviors.forEach((behavior) => { + const args: MutationBehaviorReducerArgs = { + behavior, result: action.result, variables: queryStoreValue.variables, fragmentMap: queryStoreValue.fragmentMap, @@ -110,12 +110,12 @@ export function data( config, }; - if (defaultMutationResultReducers[applyResultAction.type]) { - newState = defaultMutationResultReducers[applyResultAction.type](newState, args); - } else if (config.mutationResultReducers[applyResultAction.type]) { - newState = config.mutationResultReducers[applyResultAction.type](newState, args); + if (defaultMutationBehaviorReducers[behavior.type]) { + newState = defaultMutationBehaviorReducers[behavior.type](newState, args); + } else if (config.mutationBehaviorReducers[behavior.type]) { + newState = config.mutationBehaviorReducers[behavior.type](newState, args); } else { - throw new Error(`No mutation result reducer defined for type ${applyResultAction.type}`); + throw new Error(`No mutation result reducer defined for type ${behavior.type}`); } }); } diff --git a/src/index.ts b/src/index.ts index 602ee1b00d8..49adbe4b9ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,8 +41,8 @@ import { } from './queries/queryTransform'; import { - MutationApplyResultAction, - MutationResultReducerMap, + MutationBehavior, + MutationBehaviorReducerMap, } from './data/mutationResults'; import isUndefined = require('lodash.isundefined'); @@ -79,7 +79,7 @@ export default class ApolloClient { shouldBatch = false, ssrMode = false, ssrForceFetchDelay = 0, - mutationResultReducers = {} as MutationResultReducerMap, + mutationBehaviorReducers = {} as MutationBehaviorReducerMap, }: { networkInterface?: NetworkInterface, reduxRootKey?: string, @@ -89,7 +89,7 @@ export default class ApolloClient { shouldBatch?: boolean, ssrMode?: boolean, ssrForceFetchDelay?: number - mutationResultReducers?: MutationResultReducerMap, + mutationBehaviorReducers?: MutationBehaviorReducerMap, } = {}) { this.reduxRootKey = reduxRootKey ? reduxRootKey : 'apollo'; this.initialState = initialState ? initialState : {}; @@ -106,7 +106,7 @@ export default class ApolloClient { this.reducerConfig = { dataIdFromObject, - mutationResultReducers, + mutationBehaviorReducers, }; } @@ -128,7 +128,7 @@ export default class ApolloClient { public mutate = (options: { mutation: Document, - applyResult?: MutationApplyResultAction[], + resultBehaviors?: MutationBehavior[], variables?: Object, }): Promise => { this.initStore(); diff --git a/src/store.ts b/src/store.ts index 2a1ebef868d..71cc9f4f698 100644 --- a/src/store.ts +++ b/src/store.ts @@ -29,7 +29,7 @@ import { } from './data/extensions'; import { - MutationResultReducerMap, + MutationBehaviorReducerMap, } from './data/mutationResults'; export interface Store { @@ -110,5 +110,5 @@ export function createApolloStore({ export interface ApolloReducerConfig { dataIdFromObject?: IdGetter; - mutationResultReducers?: MutationResultReducerMap; + mutationBehaviorReducers?: MutationBehaviorReducerMap; } diff --git a/test/mutations.ts b/test/mutations.ts index 5f17e333358..988ae48d735 100644 --- a/test/mutations.ts +++ b/test/mutations.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; import ApolloClient, { addTypename } from '../src'; -import { MutationResultReducerArgs, MutationApplyResultAction } from '../src/data/mutationResults'; +import { MutationBehaviorReducerArgs, MutationBehavior } from '../src/data/mutationResults'; import { NormalizedCache, StoreObject } from '../src/data/store'; import assign = require('lodash.assign'); @@ -58,7 +58,7 @@ describe('mutation results', () => { let client: ApolloClient; let networkInterface; - type CustomMutationResultAction = { + type CustomMutationBehavior = { type: 'CUSTOM_MUTATION_RESULT', dataId: string, field: string, @@ -67,12 +67,12 @@ describe('mutation results', () => { // This is an example of a basic mutation reducer that just sets a field in the store function customMutationReducer(state: NormalizedCache, { - action, - }: MutationResultReducerArgs): NormalizedCache { - const customAction = action as any as CustomMutationResultAction; + behavior, + }: MutationBehaviorReducerArgs): NormalizedCache { + const customBehavior = behavior as any as CustomMutationBehavior; - state[customAction.dataId] = assign({}, state[customAction.dataId], { - [customAction.field]: customAction.value, + state[customBehavior.dataId] = assign({}, state[customBehavior.dataId], { + [customBehavior.field]: customBehavior.value, }) as StoreObject; return state; @@ -94,7 +94,7 @@ describe('mutation results', () => { return null; }, - mutationResultReducers: { + mutationBehaviorReducers: { 'CUSTOM_MUTATION_RESULT': customMutationReducer, }, }); @@ -188,7 +188,7 @@ describe('mutation results', () => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], @@ -218,7 +218,7 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], @@ -248,7 +248,7 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'ARRAY_INSERT', resultPath: [ 'createTodo' ], @@ -308,7 +308,7 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'DELETE', dataId: 'Todo3', @@ -359,7 +359,7 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'ARRAY_DELETE', dataId: 'Todo3', @@ -411,13 +411,13 @@ describe('mutation results', () => { .then(() => { return client.mutate({ mutation, - applyResult: [ + resultBehaviors: [ { type: 'CUSTOM_MUTATION_RESULT', dataId: 'Todo3', field: 'text', value: 'this is the new text', - } as any as MutationApplyResultAction, + } as any as MutationBehavior, ], }); }) From 36d4d1c9b579387197039c9d3d36be1f5872436c Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 22:11:36 -0700 Subject: [PATCH 31/36] Format CHANGELOG --- CHANGELOG.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01203734f1..27b3b105351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ 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 flexible mutation result handling to Apollo Client.** + - This is done by passing an `resultBehaviors` option to `client.mutate`, with an array of "Mutation Result Behaviors". + - You can attach any number of result behaviors to each mutation. + - These result behaviors are attached to the `MUTATION_RESULT` redux action that is dispatched when the query result arrives from the store, and are handled by special "Mutation Behavior Reducers". These are similar to regular Redux reducers, but they get a whole bunch of GraphQL-specific information in the arguments, and are all called synchronously in order when the result of a mutation arrives. + - In this version, Apollo Client ships with a set of default mutation result behaviors/reducers including `ARRAY_INSERT`, `DELETE`, and `ARRAY_DELETE`, but you can add any custom ones you want by passing the new `mutationBehaviorReducers` option to the `ApolloClient` constructor. + - The previous default functionality of merging all mutation results into the store is preserved. + - [PR #320](https://github.com/apollostack/apollo-client/pull/320) [Read the design in depth in Issue #317](https://github.com/apollostack/apollo-client/issues/317) - Added support for resetting the store [Issue #158](https://github.com/apollostack/apollo-client/issues/158) and [PR #314](https://github.com/apollostack/apollo-client/pull/314). - Deprecate `apollo-client/gql` for `graphql-tag` and show a meaningful warning when importing `apollo-client/gql` @@ -11,19 +19,6 @@ Expect active development and potentially significant breaking changes in the `0 - Fix unintentional breaking change where `apollo-client/gql` import stopped working. [Issue #327](https://github.com/apollostack/apollo-client/issues/327) -- **Add mutation result handling to Apollo Client.** This is done by passing an `resultBehaviors` option to -`client.mutate`, with an array of "Mutation Result Actions". You can attach any number of result -actions to each mutation. These result actions are attached to the `MUTATION_RESULT` redux action -that is dispatched when the query result arrives from the store, and are handled by special -"Mutation Result Reducers". These are special because they get a whole bunch of GraphQL-specific -information in the arguments, and are all called synchronously when the result of a mutation -arrives. In this version, Apollo Client ships with a set of default mutation result actions/reducers -including `ARRAY_INSERT`, `DELETE`, and `ARRAY_DELETE`, but you can add any custom ones you want -by passing the new `mutationBehaviorReducers` option to the `ApolloClient` constructor. The previous -default functionality of merging all mutation results into the store is preserved. -[PR #320](https://github.com/apollostack/apollo-client/pull/320) -[Read the design in depth in Issue #317](https://github.com/apollostack/apollo-client/issues/317) - ### v0.3.21 - Move out GraphQL query parsing into a new package [`graphql-tag`](https://github.com/apollostack/graphql-tag) with a backcompat shim for `apollo-client/gql`. [Issue #312](https://github.com/apollostack/apollo-client/issues/312) [PR #313](https://github.com/apollostack/apollo-client/pull/313) From 08c258d96733a836200bdd5f4b1367537e951e2e Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 22:22:36 -0700 Subject: [PATCH 32/36] Try harder to maintain array refs when unchanged --- src/data/mutationResults.ts | 43 ++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 7707b57722b..7b7ba7756d4 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -177,8 +177,15 @@ function removeRefsFromStoreObj(storeObj, dataId) { } if (isArray(value)) { - affected = true; - return cleanArray(value, dataId); + const filteredArray = cleanArray(value, dataId); + + if (filteredArray !== value) { + affected = true; + return filteredArray; + } + + // If not modified, return the original value + return value; } }); @@ -190,14 +197,34 @@ function removeRefsFromStoreObj(storeObj, dataId) { } } -function cleanArray(arr, dataId) { - if (arr.length && isArray(arr[0])) { +// Remove any occurrences of dataId in an arbitrarily nested array, and make sure that the old array +// === the new array if nothing was changed +function cleanArray(originalArray, dataId) { + if (originalArray.length && isArray(originalArray[0])) { // Handle arbitrarily nested arrays - return arr.map((nestedArray) => cleanArray(nestedArray, dataId)); + let modified = false; + const filteredArray = originalArray.map((nestedArray) => { + const nestedFilteredArray = cleanArray(nestedArray, dataId); + + if (nestedFilteredArray !== nestedArray) { + modified = true; + } + }); + + if (! modified) { + return originalArray; + } + + return filteredArray; } else { - // XXX this will create a new array reference even if no items were removed - // switch to this: https://twitter.com/leeb/status/747601132080377856 - return arr.filter((item) => item !== dataId); + const filteredArray = originalArray.filter((item) => item !== dataId); + + if (filteredArray.length === originalArray.length) { + // No items were removed, return original array + return originalArray; + } + + return filteredArray; } } From 1f993be6fa4a2a8ee20b7d6294a8f56d6d91c3e6 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 22:40:13 -0700 Subject: [PATCH 33/36] Bump filesize limit once more... I promise I'll bring it back down after this --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ba71e2b2e62..8f9ab754523 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "pretest": "npm run compile", "test": "npm run testonly --", "posttest": "npm run lint", - "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=33", + "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=34", "compile": "tsc", "compile:browser": "rm -rf ./dist && mkdir ./dist && browserify ./lib/src/index.js -o=./dist/index.js && npm run minify:browser", "minify:browser": "uglifyjs --compress --mangle --screw-ie8 -o=./dist/index.min.js -- ./dist/index.js", From 528df4c8e229a5fda9dc3d67a53ea02031dbae0f Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 23:02:31 -0700 Subject: [PATCH 34/36] Add a bunch of tests and fix bug --- src/data/mutationResults.ts | 5 +- src/data/scopeQuery.ts | 4 +- test/{mutations.ts => mutationResults.ts} | 114 +++++++++++++++++++++- test/scopeQuery.ts | 67 +++++++++++++ test/tests.ts | 2 +- 5 files changed, 187 insertions(+), 5 deletions(-) rename test/{mutations.ts => mutationResults.ts} (78%) diff --git a/src/data/mutationResults.ts b/src/data/mutationResults.ts index 7b7ba7756d4..d750edf568c 100644 --- a/src/data/mutationResults.ts +++ b/src/data/mutationResults.ts @@ -199,7 +199,7 @@ function removeRefsFromStoreObj(storeObj, dataId) { // Remove any occurrences of dataId in an arbitrarily nested array, and make sure that the old array // === the new array if nothing was changed -function cleanArray(originalArray, dataId) { +export function cleanArray(originalArray, dataId) { if (originalArray.length && isArray(originalArray[0])) { // Handle arbitrarily nested arrays let modified = false; @@ -208,7 +208,10 @@ function cleanArray(originalArray, dataId) { if (nestedFilteredArray !== nestedArray) { modified = true; + return nestedFilteredArray; } + + return nestedArray; }); if (! modified) { diff --git a/src/data/scopeQuery.ts b/src/data/scopeQuery.ts index a993a95672a..5c05e54d788 100644 --- a/src/data/scopeQuery.ts +++ b/src/data/scopeQuery.ts @@ -72,8 +72,8 @@ function followOnePathSegment( } if (matchingFields.length > 1) { - throw new Error(`Multiple fields found in query for path segment: ${pathSegment}. \ - Please file an issue on Apollo Client if you run into this situation.`); + throw new Error(`Multiple fields found in query for path segment "${pathSegment}". \ +Please file an issue on Apollo Client if you run into this situation.`); } return matchingFields[0].selectionSet; diff --git a/test/mutations.ts b/test/mutationResults.ts similarity index 78% rename from test/mutations.ts rename to test/mutationResults.ts index 988ae48d735..88b97922e9a 100644 --- a/test/mutations.ts +++ b/test/mutationResults.ts @@ -1,7 +1,7 @@ import { assert } from 'chai'; import mockNetworkInterface from './mocks/mockNetworkInterface'; import ApolloClient, { addTypename } from '../src'; -import { MutationBehaviorReducerArgs, MutationBehavior } from '../src/data/mutationResults'; +import { MutationBehaviorReducerArgs, MutationBehavior, cleanArray } from '../src/data/mutationResults'; import { NormalizedCache, StoreObject } from '../src/data/store'; import assign = require('lodash.assign'); @@ -22,6 +22,15 @@ describe('mutation results', () => { completed } } + noIdList: todoList(id: 6) { + __typename + id + todos { + __typename + text + completed + } + } } `; @@ -52,6 +61,27 @@ describe('mutation results', () => { }, ], }, + noIdList: { + __typename: 'TodoList', + id: '7', + todos: [ + { + __typename: 'Todo', + text: 'Hello world', + completed: false, + }, + { + __typename: 'Todo', + text: 'Second task', + completed: false, + }, + { + __typename: 'Todo', + text: 'Do other stuff', + completed: false, + }, + ], + }, }, }; @@ -175,6 +205,29 @@ describe('mutation results', () => { }, }; + const mutationNoId = gql` + mutation createTodo { + # skipping arguments in the test since they don't matter + createTodo { + text + completed + __typename + } + __typename + } + `; + + const mutationResultNoId = { + data: { + __typename: 'Mutation', + createTodo: { + __typename: 'Todo', + text: 'This one was created with a mutation.', + completed: true, + }, + }, + }; + it('correctly integrates a basic object at the beginning', () => { return setup({ request: { query: mutation }, @@ -240,6 +293,36 @@ describe('mutation results', () => { }); }); + it('correctly integrates a basic object at the end without id', () => { + return setup({ + request: { query: mutationNoId }, + result: mutationResultNoId, + }) + .then(() => { + return client.mutate({ + mutation: mutationNoId, + resultBehaviors: [ + { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ 'TodoList7', 'todos' ], + where: 'APPEND', + }, + ], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // There should be one more todo item than before + assert.equal(newResult.data.noIdList.todos.length, 4); + + // Since we used `APPEND` it should be at the end + assert.equal(newResult.data.noIdList.todos[3].text, 'This one was created with a mutation.'); + }); + }); + it('accepts two operations', () => { return setup({ request: { query: mutation }, @@ -430,4 +513,33 @@ describe('mutation results', () => { }); }); }); + + describe('array cleaning for ARRAY_DELETE', () => { + it('maintains reference on flat array', () => { + const array = [1, 2, 3, 4, 5]; + assert.isTrue(cleanArray(array, 6) === array); + assert.isFalse(cleanArray(array, 3) === array); + }); + + it('works on nested array', () => { + const array = [ + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + ]; + + const cleaned = cleanArray(array, 5); + assert.equal(cleaned[0].length, 4); + assert.equal(cleaned[1].length, 5); + }); + + it('maintains reference on nested array', () => { + const array = [ + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + ]; + + assert.isTrue(cleanArray(array, 11) === array); + assert.isFalse(cleanArray(array, 5) === array); + }); + }); }); diff --git a/test/scopeQuery.ts b/test/scopeQuery.ts index ef0f42912ac..247ffafb67e 100644 --- a/test/scopeQuery.ts +++ b/test/scopeQuery.ts @@ -168,6 +168,73 @@ describe('scoping selection set', () => { ['a'] ); }); + + describe('errors', () => { + it('basic collision', () => { + assert.throws(() => { + scope( + gql` + { + a { + b + } + a { + c + } + } + `, + ['a'] + ); + }, /Multiple fields found/); + }); + + it('named fragment collision', () => { + assert.throws(() => { + scope( + gql` + { + a { + b + } + ...Frag + } + + fragment Frag on Query { + a { + b + c { + d + } + } + } + `, + ['a'] + ); + }, /Multiple fields found/); + }); + + it('inline fragment collision', () => { + assert.throws(() => { + scope(gql` + { + a { + b + } + ... on Query { + a { + b + c { + d + } + } + } + } + `, + ['a'] + ); + }, /Multiple fields found/); + }); + }); }); function extractMainSelectionSet(doc) { diff --git a/test/tests.ts b/test/tests.ts index 8138d1ab465..0d290b3a0cc 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -22,5 +22,5 @@ import './directives'; import './queryMerging'; import './batching'; import './scheduler'; -import './mutations'; +import './mutationResults'; import './scopeQuery'; From 41c67bf865fc201b6c47947a84895014717f5999 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 23:10:02 -0700 Subject: [PATCH 35/36] Add one more test --- test/scopeQuery.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/scopeQuery.ts b/test/scopeQuery.ts index 247ffafb67e..3140f3b7f22 100644 --- a/test/scopeQuery.ts +++ b/test/scopeQuery.ts @@ -170,6 +170,21 @@ describe('scoping selection set', () => { }); describe('errors', () => { + it('field missing', () => { + assert.throws(() => { + scope( + gql` + { + a { + b + } + } + `, + ['c'] + ); + }, /No matching field/); + }); + it('basic collision', () => { assert.throws(() => { scope( From 0c68047ad0d34f15cc73b37b04af6807ff6e9f77 Mon Sep 17 00:00:00 2001 From: Sashko Stubailo Date: Tue, 28 Jun 2016 23:32:08 -0700 Subject: [PATCH 36/36] Add test with arguments on field --- CHANGELOG.md | 1 + src/data/storeUtils.ts | 13 +++++++++---- src/index.ts | 6 ++++++ test/mutationResults.ts | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b3b105351..7bd20e7ad46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Expect active development and potentially significant breaking changes in the `0 - These result behaviors are attached to the `MUTATION_RESULT` redux action that is dispatched when the query result arrives from the store, and are handled by special "Mutation Behavior Reducers". These are similar to regular Redux reducers, but they get a whole bunch of GraphQL-specific information in the arguments, and are all called synchronously in order when the result of a mutation arrives. - In this version, Apollo Client ships with a set of default mutation result behaviors/reducers including `ARRAY_INSERT`, `DELETE`, and `ARRAY_DELETE`, but you can add any custom ones you want by passing the new `mutationBehaviorReducers` option to the `ApolloClient` constructor. - The previous default functionality of merging all mutation results into the store is preserved. + - Added `client.dataId` and `client.fieldWithArgs` helpers to generate store paths for mutation behaviors. - [PR #320](https://github.com/apollostack/apollo-client/pull/320) [Read the design in depth in Issue #317](https://github.com/apollostack/apollo-client/issues/317) - Added support for resetting the store [Issue #158](https://github.com/apollostack/apollo-client/issues/158) and [PR #314](https://github.com/apollostack/apollo-client/pull/314). - Deprecate `apollo-client/gql` for `graphql-tag` and show a meaningful warning when importing diff --git a/src/data/storeUtils.ts b/src/data/storeUtils.ts index b29655a7e80..a9e38879e2b 100644 --- a/src/data/storeUtils.ts +++ b/src/data/storeUtils.ts @@ -73,16 +73,21 @@ export function storeKeyNameFromField(field: Field, variables?: Object): string if (field.arguments && field.arguments.length) { const argObj: Object = {}; - field.arguments.forEach(({name, value}) => valueToObjectRepresentation(argObj, name, value, variables)); + field.arguments.forEach(({name, value}) => valueToObjectRepresentation( + argObj, name, value, variables)); - const stringifiedArgs: string = JSON.stringify(argObj); - - return `${field.name.value}(${stringifiedArgs})`; + return storeKeyNameFromFieldNameAndArgs(field.name.value, argObj); } return field.name.value; } +export function storeKeyNameFromFieldNameAndArgs(fieldName: string, args?: Object): string { + const stringifiedArgs: string = JSON.stringify(args); + + return `${fieldName}(${stringifiedArgs})`; +} + export function resultKeyNameFromField(field: Field): string { return field.alias ? field.alias.value : diff --git a/src/index.ts b/src/index.ts index 49adbe4b9ca..ad3f06cd009 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,10 @@ import { MutationBehaviorReducerMap, } from './data/mutationResults'; +import { + storeKeyNameFromFieldNameAndArgs, +} from './data/storeUtils'; + import isUndefined = require('lodash.isundefined'); export { @@ -69,6 +73,7 @@ export default class ApolloClient { public shouldBatch: boolean; public shouldForceFetch: boolean; public dataId: IdGetter; + public fieldWithArgs: (fieldName: string, args?: Object) => string; constructor({ networkInterface, @@ -99,6 +104,7 @@ export default class ApolloClient { this.shouldBatch = shouldBatch; this.shouldForceFetch = !(ssrMode || ssrForceFetchDelay > 0); this.dataId = dataIdFromObject; + this.fieldWithArgs = storeKeyNameFromFieldNameAndArgs; if (ssrForceFetchDelay) { setTimeout(() => this.shouldForceFetch = true, ssrForceFetchDelay); diff --git a/test/mutationResults.ts b/test/mutationResults.ts index 88b97922e9a..4e71d86268c 100644 --- a/test/mutationResults.ts +++ b/test/mutationResults.ts @@ -16,8 +16,14 @@ describe('mutation results', () => { __typename id todos { + id __typename + text + completed + } + filteredTodos: todos(completed: true) { id + __typename text completed } @@ -60,6 +66,7 @@ describe('mutation results', () => { completed: false, }, ], + filteredTodos: [], }, noIdList: { __typename: 'TodoList', @@ -293,6 +300,37 @@ describe('mutation results', () => { }); }); + it('correctly integrates a basic object at the end with arguments', () => { + return setup({ + request: { query: mutation }, + result: mutationResult, + }) + .then(() => { + return client.mutate({ + mutation, + resultBehaviors: [ + { + type: 'ARRAY_INSERT', + resultPath: [ 'createTodo' ], + storePath: [ + 'TodoList5', + client.fieldWithArgs('todos', {completed: true}), + ], + where: 'APPEND', + }, + ], + }); + }) + .then(() => { + return client.query({ query }); + }) + .then((newResult: any) => { + // There should be one more todo item than before + assert.equal(newResult.data.todoList.filteredTodos.length, 1); + assert.equal(newResult.data.todoList.filteredTodos[0].text, 'This one was created with a mutation.'); + }); + }); + it('correctly integrates a basic object at the end without id', () => { return setup({ request: { query: mutationNoId },