Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mutation reducers #404

Merged
merged 8 commits into from
Jul 18, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
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
- Introduce a new (preferable) way to express how the mutation result should be incorporated into the store and update watched queries results: `updateQueries`. [PR #404](https://github.com/apollostack/apollo-client/pull/404).
- Writing query results to store no longer creates new objects (and new references) in cases when the new value is identical to the old value in the store.

### v0.4.2

Expand Down
82 changes: 77 additions & 5 deletions src/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
GraphQLResult,
Document,
FragmentDefinition,
OperationDefinition,
} from 'graphql';

import { print } from 'graphql-tag/printer';
Expand All @@ -55,6 +56,7 @@ import {

import {
MutationBehavior,
MutationQueryReducersMap,
} from './data/mutationResults';

import {
Expand Down Expand Up @@ -96,7 +98,7 @@ export class ObservableQuery extends Observable<ApolloQueryResult> {
public startPolling: (p: number) => void;
public options: WatchQueryOptions;
public queryManager: QueryManager;
private queryId: string;
public queryId: string;

constructor({
queryManager,
Expand Down Expand Up @@ -282,15 +284,17 @@ export class QueryManager {
public mutate({
mutation,
variables,
resultBehaviors,
resultBehaviors = [],
fragments = [],
optimisticResponse,
updateQueries,
}: {
mutation: Document,
variables?: Object,
resultBehaviors?: MutationBehavior[],
fragments?: FragmentDefinition[],
optimisticResponse?: Object,
updateQueries?: MutationQueryReducersMap,
}): Promise<ApolloQueryResult> {
const mutationId = this.generateQueryId();

Expand All @@ -313,6 +317,14 @@ export class QueryManager {
operationName: getOperationName(mutation),
} as Request;

// Right now the way `updateQueries` feature is implemented relies on using
// `resultBehaviors`, another feature that accomplishes the same goal but
// provides more verbose syntax.
// In the future we want to re-factor this part of code to avoid using
// `resultBehaviors` so we can remove `resultBehaviors` entirely.
const updateQueriesResultBehaviors = !optimisticResponse ? [] :
this.collectResultBehaviorsFromUpdateQueries(updateQueries, { data: optimisticResponse }, true);

this.store.dispatch({
type: 'APOLLO_MUTATION_INIT',
mutationString,
Expand All @@ -325,7 +337,7 @@ export class QueryManager {
mutationId,
fragmentMap: queryFragmentMap,
optimisticResponse,
resultBehaviors,
resultBehaviors: [...resultBehaviors, ...updateQueriesResultBehaviors],
});

return this.networkInterface.query(request)
Expand All @@ -334,7 +346,10 @@ export class QueryManager {
type: 'APOLLO_MUTATION_RESULT',
result,
mutationId,
resultBehaviors,
resultBehaviors: [
...resultBehaviors,
...this.collectResultBehaviorsFromUpdateQueries(updateQueries, result),
],
});

return result;
Expand All @@ -344,7 +359,6 @@ export class QueryManager {
type: 'APOLLO_MUTATION_ERROR',
error: err,
mutationId,
resultBehaviors,
});

return Promise.reject(err);
Expand Down Expand Up @@ -584,6 +598,64 @@ export class QueryManager {
});
}

private collectResultBehaviorsFromUpdateQueries(
updateQueries: MutationQueryReducersMap,
mutationResult: Object,
isOptimistic = false
): MutationBehavior[] {
if (!updateQueries) {
return [];
}
const resultBehaviors = [];

const observableQueriesByName: { [name: string]: ObservableQuery[] } = {};
Object.keys(this.observableQueries).forEach((key) => {
const observableQuery = this.observableQueries[key].observableQuery;
const queryName = getQueryDefinition(observableQuery.options.query).name.value;

observableQueriesByName[queryName] =
observableQueriesByName[queryName] || [];
observableQueriesByName[queryName].push(observableQuery);
});

Object.keys(updateQueries).forEach((queryName) => {
const reducer = updateQueries[queryName];
const queries = observableQueriesByName[queryName];
if (!queries) {
// XXX should throw an error?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should throw an error, because it's pretty reasonable to have some that are not currently on the page... but I agree it could be useful to have something, since people might be confused about why their code is not running.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting to not throwing anything here.

return;
}

queries.forEach((observableQuery) => {
const queryOptions = observableQuery.options;
const queryDefinition: OperationDefinition = getQueryDefinition(queryOptions.query);
const previousResult = readSelectionSetFromStore({
// In case of an optimistic change, apply reducer on top of the
// results including previous optimistic updates. Otherwise, apply it
// on top of the real data only.
store: isOptimistic ? this.getDataWithOptimisticResults() : this.getApolloState().data,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a comment about why we have this conditional - it's because we want to apply new optimistic results on top of old optimistic results, but real results on top of real data only.

rootId: 'ROOT_QUERY',
selectionSet: queryDefinition.selectionSet,
variables: queryOptions.variables,
returnPartialData: queryOptions.returnPartialData || queryOptions.noFetch,
fragmentMap: createFragmentMap(queryOptions.fragments || []),
});

resultBehaviors.push({
type: 'QUERY_RESULT',
newResult: reducer(previousResult, {
mutationResult,
queryName,
queryVariables: queryOptions.variables,
}),
queryOptions,
});
});
});

return resultBehaviors;
}

private fetchQueryOverInterface(
queryId: string,
options: WatchQueryOptions,
Expand Down
1 change: 0 additions & 1 deletion src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export interface MutationErrorAction {
type: 'APOLLO_MUTATION_ERROR';
error: Error;
mutationId: string;
resultBehaviors?: MutationBehavior[];
};

export function isMutationErrorAction(action: ApolloAction): action is MutationErrorAction {
Expand Down
50 changes: 49 additions & 1 deletion src/data/mutationResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import {
GraphQLResult,
SelectionSet,
OperationDefinition,
} from 'graphql';

import mapValues = require('lodash.mapvalues');
Expand All @@ -14,6 +15,8 @@ import assign = require('lodash.assign');

import {
FragmentMap,
getQueryDefinition,
createFragmentMap,
} from '../queries/getFromAST';

import {
Expand All @@ -30,12 +33,17 @@ import {
writeSelectionSetToStore,
} from './writeToStore';

import {
WatchQueryOptions,
} from '../QueryManager';

// Mutation behavior types, these can be used in the `resultBehaviors` argument to client.mutate

export type MutationBehavior =
MutationArrayInsertBehavior |
MutationArrayDeleteBehavior |
MutationDeleteBehavior;
MutationDeleteBehavior |
MutationQueryResultBehavior;

export type MutationArrayInsertBehavior = {
type: 'ARRAY_INSERT';
Expand All @@ -55,6 +63,12 @@ export type MutationArrayDeleteBehavior = {
dataId: string;
}

export type MutationQueryResultBehavior = {
type: 'QUERY_RESULT';
queryOptions: WatchQueryOptions;
newResult: Object;
};

export type ArrayInsertWhere =
'PREPEND' |
'APPEND';
Expand Down Expand Up @@ -254,10 +268,44 @@ function mutationResultArrayDeleteReducer(state: NormalizedCache, {
}) as NormalizedCache;
}

function mutationResultQueryResultReducer(state: NormalizedCache, {
behavior,
config,
}: MutationBehaviorReducerArgs) {
const {
queryOptions,
newResult,
} = behavior as MutationQueryResultBehavior;

const clonedState = assign({}, state) as NormalizedCache;
const queryDefinition: OperationDefinition = getQueryDefinition(queryOptions.query);

return writeSelectionSetToStore({
result: newResult,
dataId: 'ROOT_QUERY',
selectionSet: queryDefinition.selectionSet,
variables: queryOptions.variables,
store: clonedState,
dataIdFromObject: config.dataIdFromObject,
fragmentMap: createFragmentMap(queryOptions.fragments || []),
});
}

export type MutationQueryReducer = (previousResult: Object, options: {
mutationResult: Object,
queryName: Object,
queryVariables: Object,
}) => Object;

export type MutationQueryReducersMap = {
[queryName: string]: MutationQueryReducer;
};

// 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,
'QUERY_RESULT': mutationResultQueryResultReducer,
};
4 changes: 3 additions & 1 deletion src/data/writeToStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,7 @@ function writeFieldToStore({
[storeFieldName]: storeValue,
}) as StoreObject;

store[dataId] = newStoreObj;
if (!store[dataId] || storeValue !== store[dataId][storeFieldName]) {
store[dataId] = newStoreObj;
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import {
MutationBehavior,
MutationBehaviorReducerMap,
MutationQueryReducersMap,
} from './data/mutationResults';

import {
Expand Down Expand Up @@ -236,6 +237,7 @@ export default class ApolloClient {
resultBehaviors?: MutationBehavior[],
fragments?: FragmentDefinition[],
optimisticResponse?: Object,
updateQueries?: MutationQueryReducersMap,
}): Promise<ApolloQueryResult> => {
this.initStore();
return this.queryManager.mutate(options);
Expand Down
65 changes: 64 additions & 1 deletion test/mutationResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MutationBehaviorReducerArgs, MutationBehavior, cleanArray } from '../sr
import { NormalizedCache, StoreObject } from '../src/data/store';

import assign = require('lodash.assign');
import clonedeep = require('lodash.clonedeep');

import gql from 'graphql-tag';

Expand Down Expand Up @@ -136,9 +137,11 @@ describe('mutation results', () => {
},
});

return client.query({
const obsHandle = client.watchQuery({
query,
});

return obsHandle.result();
};

it('correctly primes cache for tests', () => {
Expand Down Expand Up @@ -582,4 +585,64 @@ describe('mutation results', () => {
assert.isFalse(cleanArray(array, 5) === array);
});
});

describe('query result reducers', () => {
const mutation = gql`
mutation createTodo {
# skipping arguments in the test since they don't matter
createTodo {
id
text
completed
__typename
}
__typename
}
`;

const mutationResult = {
data: {
__typename: 'Mutation',
createTodo: {
id: '99',
__typename: 'Todo',
text: 'This one was created with a mutation.',
completed: true,
},
},
};

it('analogous of ARRAY_INSERT', () => {
return setup({
request: { query: mutation },
result: mutationResult,
})
.then(() => {
return client.mutate({
mutation,
updateQueries: {
todoList: (prev, options) => {
const mResult = options.mutationResult as any;
assert.equal(mResult.data.createTodo.id, '99');
assert.equal(mResult.data.createTodo.text, 'This one was created with a mutation.');

const state = clonedeep(prev) as any;
state.todoList.todos.unshift(mResult.data.createTodo);
return state;
},
},
});
})
.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.');
});
});
});
});
Loading