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 results #320

Merged
merged 36 commits into from
Jun 29, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
71934a6
Set up some tests
Jun 27, 2016
a896130
Fix stuff
Jun 27, 2016
f9a89e2
Write test for already working mutation
Jun 27, 2016
e49d027
Failing test for array insert
Jun 27, 2016
f9eec6b
Get to a reasonable failing state
Jun 27, 2016
6fc9190
Commit new file
Jun 27, 2016
ebbd0f4
WIP
Jun 27, 2016
d59657b
Missing file
Jun 27, 2016
14f8d29
Start on scoping query selection set
Jun 27, 2016
e24a4d5
Finish scoping
Jun 27, 2016
c2f5cae
Implement basic ARRAY_INSERT
Jun 28, 2016
696ecad
Implement APPEND
Jun 28, 2016
de3fe00
Refactor, and add another test
Jun 28, 2016
db03820
Start on delete
Jun 28, 2016
77f2fc4
Delete object from array
Jun 28, 2016
1268180
Add comment
Jun 28, 2016
61d4c2e
Add ref to tweet
Jun 28, 2016
2ac9b67
Add warning
Jun 28, 2016
d5ac174
Improve test
Jun 28, 2016
c851979
Add ARRAY_DELETE
Jun 28, 2016
034b8b7
Implement and test custom reducers
Jun 28, 2016
3ec8ec3
Bump size limit
Jun 28, 2016
54b6c33
Fix lint errors
Jun 28, 2016
8598615
Add transformer
Jun 28, 2016
4f989ba
Expose IdGetter
Jun 28, 2016
a7b4e81
Add comments
Jun 28, 2016
8ad1999
Fix some bugs
Jun 28, 2016
80c2df1
Add changelog entry
Jun 28, 2016
0362626
change arguments around
abhiaiyer91 Jun 28, 2016
2eca76d
Rename mutation result actions to mutation behaviors
Jun 29, 2016
36d4d1c
Format CHANGELOG
Jun 29, 2016
08c258d
Try harder to maintain array refs when unchanged
Jun 29, 2016
1f993be
Bump filesize limit once more...
Jun 29, 2016
528df4c
Add a bunch of tests and fix bug
Jun 29, 2016
41c67bf
Add one more test
Jun 29, 2016
0c68047
Add test with arguments on field
Jun 29, 2016
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
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.
- 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
`apollo-client/gql`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions src/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
diffSelectionSetAgainstStore,
} from './data/diffAgainstStore';

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

import {
queryDocument,
} from './queryPrinting';
Expand Down Expand Up @@ -193,9 +197,11 @@ export class QueryManager {
public mutate({
mutation,
variables,
resultBehaviors,
}: {
mutation: Document,
variables?: Object,
resultBehaviors?: MutationBehavior[],
}): Promise<GraphQLResult> {
const mutationId = this.generateQueryId();

Expand Down Expand Up @@ -232,6 +238,7 @@ export class QueryManager {
type: 'APOLLO_MUTATION_RESULT',
result,
mutationId,
resultBehaviors,
});

return result;
Expand Down
5 changes: 5 additions & 0 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
SelectionSetWithRoot,
} from './queries/store';

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

import { FragmentMap } from './queries/getFromAST';

export interface QueryResultAction {
Expand Down Expand Up @@ -85,6 +89,7 @@ export interface MutationResultAction {
type: 'APOLLO_MUTATION_RESULT';
result: GraphQLResult;
mutationId: string;
resultBehaviors?: MutationBehavior[];
}

export function isMutationResultAction(action: ApolloAction): action is MutationResultAction {
Expand Down
263 changes: 263 additions & 0 deletions src/data/mutationResults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import {
NormalizedCache,
} from './store';

import {
GraphQLResult,
SelectionSet,
} from 'graphql';

import mapValues = require('lodash.mapvalues');
import isArray = require('lodash.isarray');
import cloneDeep = require('lodash.clonedeep');
import assign = require('lodash.assign');

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

import {
scopeSelectionSetToResultPath,
scopeJSONToResultPath,
StorePath,
} from './scopeQuery';

import {
ApolloReducerConfig,
} from '../store';

import {
writeSelectionSetToStore,
} from './writeToStore';

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

export type MutationBehavior =
MutationArrayInsertBehavior |
MutationArrayDeleteBehavior |
MutationDeleteBehavior;

export type MutationArrayInsertBehavior = {
type: 'ARRAY_INSERT';
resultPath: StorePath;
storePath: StorePath;
where: ArrayInsertWhere;
}

export type MutationDeleteBehavior = {
type: 'DELETE';
dataId: string;
}

export type MutationArrayDeleteBehavior = {
type: 'ARRAY_DELETE';
Copy link
Contributor

Choose a reason for hiding this comment

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

we should prefix with APOLLO_ to be consistent no?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These aren't real actions - they end up in the applyResults section of the MUTATION_RESULT action, here: https://github.com/apollostack/apollo-client/pull/320/files#diff-162241b86351f640a8bb3127ed5fbd5bR92

This is because we want to process all of the mutation result stuff synchronously in one pass, rather than as multiple actions one after the other - it seems weird to me to fire many actions in one tick, when they represent one event (a mutation arriving from the server).

Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make sense to not call these actions at all? I was confused at first by this as well. It seems that these are more like "action applications" although I don't think that is a good name for them. I just think calling them "actions" makes it seem as though they are actual actions which will lead to a transition within the Redux store which can be a bit confusing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'm going to call them "behaviors" after thinking about it.

storePath: StorePath;
dataId: string;
}

export type ArrayInsertWhere =
'PREPEND' |
'APPEND';

// These are the generic arguments passed into the mutation result reducers
// The `behavior` field is specific to each reducer
export type MutationBehaviorReducerArgs = {
behavior: MutationBehavior;
result: GraphQLResult;
variables: any;
fragmentMap: FragmentMap;
selectionSet: SelectionSet;
config: ApolloReducerConfig;
}

export type MutationBehaviorReducerMap = {
[type: string]: MutationBehaviorReducer;
}

export type MutationBehaviorReducer = (state: NormalizedCache, args: MutationBehaviorReducerArgs) => NormalizedCache;

// Reducer for ARRAY_INSERT behavior
function mutationResultArrayInsertReducer(state: NormalizedCache, {
behavior,
result,
variables,
fragmentMap,
selectionSet,
config,
}: MutationBehaviorReducerArgs): NormalizedCache {
const {
resultPath,
storePath,
where,
} = behavior as MutationArrayInsertBehavior;

// Step 1: get selection set and result for resultPath
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
const dataId = config.dataIdFromObject(scopedResult) || generateMutationResultDataId();

// Step 2: insert object into store with writeSelectionSet
state = writeSelectionSetToStore({
result: scopedResult,
dataId,
selectionSet: scopedSelectionSet,
store: state,
variables,
dataIdFromObject: config.dataIdFromObject,
fragmentMap,
});

// Step 3: insert dataId reference into storePath array
const dataIdOfObj = storePath.shift();
const clonedObj = cloneDeep(state[dataIdOfObj]);
const array = scopeJSONToResultPath({
json: clonedObj,
path: storePath,
});

if (where === 'PREPEND') {
array.unshift(dataId);
} else if (where === 'APPEND') {
array.push(dataId);
} else {
throw new Error('Unsupported "where" option to ARRAY_INSERT.');
}

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' behavior
function mutationResultDeleteReducer(state: NormalizedCache, {
behavior,
}: MutationBehaviorReducerArgs): NormalizedCache {
const {
dataId,
} = behavior as MutationDeleteBehavior;

// Delete the object
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;

const cleanedObj = mapValues(storeObj, (value, key) => {
if (value === dataId) {
affected = true;
return null;
}

if (isArray(value)) {
const filteredArray = cleanArray(value, dataId);

if (filteredArray !== value) {
affected = true;
return filteredArray;
}

// If not modified, return the original value
return value;
}
});

if (affected) {
// Maintain === for unchanged objects
Copy link
Contributor

@Poincare Poincare Jun 28, 2016

Choose a reason for hiding this comment

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

If I'm not misunderstanding this, we won't be able to maintain === if we have an array within the object and we don't end up modifying any of that array because we'll set affected=true regardless of whether the array was modified.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

return cleanedObj;
} else {
return storeObj;
}
}

// Remove any occurrences of dataId in an arbitrarily nested array, and make sure that the old array
// === the new array if nothing was changed
export function cleanArray(originalArray, dataId) {
if (originalArray.length && isArray(originalArray[0])) {
// Handle arbitrarily nested arrays
let modified = false;
const filteredArray = originalArray.map((nestedArray) => {
const nestedFilteredArray = cleanArray(nestedArray, dataId);

if (nestedFilteredArray !== nestedArray) {
modified = true;
return nestedFilteredArray;
}

return nestedArray;
});

if (! modified) {
return originalArray;
}

return filteredArray;
} else {
const filteredArray = originalArray.filter((item) => item !== dataId);

if (filteredArray.length === originalArray.length) {
// No items were removed, return original array
return originalArray;
}

return filteredArray;
}
}

// Reducer for 'ARRAY_DELETE' behavior
function mutationResultArrayDeleteReducer(state: NormalizedCache, {
behavior,
}: MutationBehaviorReducerArgs): NormalizedCache {
const {
dataId,
storePath,
} = behavior as MutationArrayDeleteBehavior;

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;
}

// 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,
};
Loading