Repository: apollographql/apollo-client

Problem statement:
Add support for GraphQL variables default values
Spec: https://facebook.github.io/graphql/#sec-Language.Variables
More info: https://medium.com/the-graphqlhub/graphql-tour-variables-58c6abd10f56


Hints:
@iki sure, we'll implement it some time. I would give this relatively low priority at the moment though, because I think default values for variables are most useful on the server. On the client, I think it's just as convenient to provide that default value yourself in the component that issues the query. But maybe you have a compelling use-case?
One use case I've run into is with directives.

For example, if I want to reuse a query for different components, but they query a different subset of fields:

```
query user($id: Int, $withGroups: Boolean = false, $withFriends: Boolean = false) {
    user(id: $id) {
      id
      username
      friends @include(if: $withFriends) {
        id
        username
      }
      groups @include(if: $withGroups) {
        id
        name
        messages(limit: 1) {
          ... MessageFragment
        }
      }
    }
  }
```

Here, it would be nice to default to false for `withGroups` and `withFriends`, and only opt-in with variables. It's a small change, but I imagine could get more complicated if you wanted to have optional filters with defaults, etc.

Maybe there's a nicer way to do this?

Also, when running the code with default values, I would receive an error, but the error message wasn't serializable so it didn't make it into reducer, so I needed to create middleware to log it. I'm working on a RN app so no apollo dev tools yet. I don't know if this is a general issue for some subset of errors that could use improving, but wanted to bring it to your attention, and happy to help if so.

@srtucker22 this is a feature we would be happy to merge if you wanted to implement it 😊
looking into it. got my apollo-client contributor env setup. hopefully will have some free time soon to mess with it
@calebmer another common use case is simply whenever it's more appropriate in codebase to setup default query/mutation parameter values in gql, e.h. if they are internal parameters and the user module which attaches given query/mutation to a redux component should not care about those parameters at all.

Patch:
diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts
--- a/src/core/QueryManager.ts
+++ b/src/core/QueryManager.ts
@@ -37,7 +37,10 @@ import {
 import {
   checkDocument,
   getQueryDefinition,
+  getOperationDefinition,
   getOperationName,
+  getDefaultValues,
+  getMutationDefinition,
 } from '../queries/getFromAST';
 
 import {
@@ -255,7 +258,8 @@ export class QueryManager {
       mutation = addTypenameToDocument(mutation);
     }
 
-    checkDocument(mutation);
+    variables = Object.assign(getDefaultValues(getMutationDefinition(mutation)), variables);
+
     const mutationString = print(mutation);
     const request = {
       query: mutation,
@@ -628,8 +632,15 @@ export class QueryManager {
     }
 
 
-    // Call just to get errors synchronously
-    getQueryDefinition(options.query);
+    // get errors synchronously
+    const queryDefinition = getQueryDefinition(options.query);
+
+    // assign variable default values if supplied
+    if (queryDefinition.variableDefinitions && queryDefinition.variableDefinitions.length) {
+      const defaultValues = getDefaultValues(queryDefinition);
+
+      options.variables = Object.assign(defaultValues, options.variables);
+    }
 
     if (typeof options.notifyOnNetworkStatusChange === 'undefined') {
       options.notifyOnNetworkStatusChange = false;
@@ -818,13 +829,15 @@ export class QueryManager {
   ): Observable<any> {
     const {
       query,
-      variables,
     } = options;
     let transformedDoc = query;
     // Apply the query transformer if one has been provided.
     if (this.addTypename) {
       transformedDoc = addTypenameToDocument(transformedDoc);
     }
+
+    const variables = Object.assign(getDefaultValues(getOperationDefinition(query)), options.variables);
+
     const request: Request = {
       query: transformedDoc,
       variables,
@@ -854,7 +867,7 @@ export class QueryManager {
               document: transformedDoc,
               operationName: getOperationName(transformedDoc),
               result: { data: result },
-              variables: variables || {},
+              variables,
               subscriptionId: subId,
               extraReducers: this.getExtraReducers(),
             });
diff --git a/src/data/readFromStore.ts b/src/data/readFromStore.ts
--- a/src/data/readFromStore.ts
+++ b/src/data/readFromStore.ts
@@ -20,6 +20,7 @@ import {
 } from './storeUtils';
 
 import {
+  getDefaultValues,
   getQueryDefinition,
 } from '../queries/getFromAST';
 
@@ -202,7 +203,9 @@ export function diffQueryAgainstStore({
   config,
 }: DiffQueryAgainstStoreOptions): DiffResult {
   // Throw the right validation error by trying to find a query in the document
-  getQueryDefinition(query);
+  const queryDefinition = getQueryDefinition(query);
+
+  variables = Object.assign(getDefaultValues(queryDefinition), variables);
 
   const context: ReadStoreContext = {
     // Global settings
diff --git a/src/data/storeUtils.ts b/src/data/storeUtils.ts
--- a/src/data/storeUtils.ts
+++ b/src/data/storeUtils.ts
@@ -47,7 +47,7 @@ function isEnumValue(value: ValueNode): value is EnumValueNode {
   return value.kind === 'EnumValue';
 }
 
-function valueToObjectRepresentation(argObj: any, name: NameNode, value: ValueNode, variables?: Object) {
+export function valueToObjectRepresentation(argObj: any, name: NameNode, value: ValueNode, variables?: Object) {
   if (isIntValue(value) || isFloatValue(value)) {
     argObj[name.value] = Number(value.value);
   } else if (isBooleanValue(value) || isStringValue(value)) {
diff --git a/src/data/writeToStore.ts b/src/data/writeToStore.ts
--- a/src/data/writeToStore.ts
+++ b/src/data/writeToStore.ts
@@ -1,5 +1,6 @@
 
 import {
+  getDefaultValues,
   getOperationDefinition,
   getQueryDefinition,
   FragmentMap,
@@ -73,6 +74,8 @@ export function writeQueryToStore({
 }): NormalizedCache {
   const queryDefinition: OperationDefinitionNode = getQueryDefinition(query);
 
+  variables = Object.assign(getDefaultValues(queryDefinition), variables);
+
   return writeSelectionSetToStore({
     dataId: 'ROOT_QUERY',
     result,
@@ -110,9 +113,12 @@ export function writeResultToStore({
 }): NormalizedCache {
 
   // XXX TODO REFACTOR: this is a temporary workaround until query normalization is made to work with documents.
-  const selectionSet = getOperationDefinition(document).selectionSet;
+  const operationDefinition = getOperationDefinition(document);
+  const selectionSet = operationDefinition.selectionSet;
   const fragmentMap = createFragmentMap(getFragmentDefinitions(document));
 
+  variables = Object.assign(getDefaultValues(operationDefinition), variables);
+
   return writeSelectionSetToStore({
     result,
     dataId,
diff --git a/src/queries/getFromAST.ts b/src/queries/getFromAST.ts
--- a/src/queries/getFromAST.ts
+++ b/src/queries/getFromAST.ts
@@ -2,9 +2,14 @@ import {
   DocumentNode,
   OperationDefinitionNode,
   FragmentDefinitionNode,
+  ValueNode,
 } from 'graphql';
 
 
+import {
+  valueToObjectRepresentation,
+} from '../data/storeUtils';
+
 export function getMutationDefinition(doc: DocumentNode): OperationDefinitionNode {
   checkDocument(doc);
 
@@ -229,3 +234,24 @@ export function getFragmentQueryDocument(document: DocumentNode, fragmentName?:
 
   return query;
 }
+
+export function getDefaultValues(definition: OperationDefinitionNode): { [key: string]: any } {
+  if (definition.variableDefinitions && definition.variableDefinitions.length) {
+    const defaultValues = definition.variableDefinitions
+      .filter(({ defaultValue }) => defaultValue)
+      .map(({ variable, defaultValue }) : { [key: string]: any } => {
+        const defaultValueObj: { [key: string]: any } = {};
+        valueToObjectRepresentation(
+          defaultValueObj,
+          variable.name,
+          defaultValue as ValueNode,
+        );
+
+        return defaultValueObj;
+      });
+
+    return Object.assign({}, ...defaultValues);
+  }
+
+  return {};
+}


Test patch:
diff --git a/test/ApolloClient.ts b/test/ApolloClient.ts
--- a/test/ApolloClient.ts
+++ b/test/ApolloClient.ts
@@ -103,6 +103,40 @@ describe('ApolloClient', () => {
     });
   });
 
+  it('will read some data from the store with default values', () => {
+    const client = new ApolloClient({
+      initialState: {
+        apollo: {
+          data: {
+            'ROOT_QUERY': {
+              'field({"literal":true,"value":-1})': 1,
+              'field({"literal":false,"value":42})': 2,
+            },
+          },
+        },
+      },
+    });
+
+    assert.deepEqual(client.readQuery({
+      query: gql`query ($literal: Boolean, $value: Int = -1) {
+        a: field(literal: $literal, value: $value)
+      }`,
+      variables: {
+        literal: false,
+        value: 42,
+      },
+    }), { a: 2 });
+
+    assert.deepEqual(client.readQuery({
+      query: gql`query ($literal: Boolean, $value: Int = -1) {
+        a: field(literal: $literal, value: $value)
+      }`,
+      variables: {
+        literal: true,
+      },
+    }), { a: 1 });
+  });
+
   describe('readFragment', () => {
     it('will throw an error when there is no fragment', () => {
       const client = new ApolloClient();
@@ -399,6 +433,46 @@ describe('ApolloClient', () => {
         },
       });
     });
+
+    it('will write some data to the store with default values for variables', () => {
+      const client = new ApolloClient();
+
+      client.writeQuery({
+        data: {
+          a: 2,
+        },
+        query: gql`
+          query ($literal: Boolean, $value: Int = -1) {
+            a: field(literal: $literal, value: $value)
+          }
+        `,
+        variables: {
+          literal: true,
+          value: 42,
+        },
+      });
+
+      client.writeQuery({
+        data: {
+          a: 1,
+        },
+        query: gql`
+          query ($literal: Boolean, $value: Int = -1) {
+            a: field(literal: $literal, value: $value)
+          }
+        `,
+        variables: {
+          literal: false,
+        },
+      });
+
+      assert.deepEqual(client.store.getState().apollo.data, {
+        'ROOT_QUERY': {
+          'field({"literal":true,"value":42})': 2,
+          'field({"literal":false,"value":-1})': 1,
+        },
+      });
+    });
   });
 
   describe('writeFragment', () => {
@@ -1148,3 +1222,4 @@ describe('ApolloClient', () => {
     });
   });
 });
+
diff --git a/test/client.ts b/test/client.ts
--- a/test/client.ts
+++ b/test/client.ts
@@ -264,7 +264,123 @@ describe('client', () => {
       },
     };
 
-    clientRoundrip(query, data);
+    clientRoundtrip(query, data);
+  });
+
+  it('should allow for a single query with complex default variables to take place', (done) => {
+    const query = gql`
+      query stuff($test: Input = {key1: ["value", "value2"], key2: {key3: 4}}) {
+        allStuff(test: $test) {
+          people {
+            name
+          }
+        }
+      }
+    `;
+
+    const result = {
+      allStuff: {
+        people: [
+          {
+            name: 'Luke Skywalker',
+          },
+          {
+            name: 'Jabba The Hutt',
+          },
+        ],
+      },
+    };
+
+    const variables = {test: { key1: ['value', 'value2'], key2: { key3: 4 } } };
+
+    const networkInterface = mockNetworkInterface({
+      request: { query, variables },
+      result: { data: result },
+    });
+
+    const client = new ApolloClient({
+      networkInterface,
+      addTypename: false,
+    });
+
+    const basic = client.query({ query, variables }).then((actualResult) => {
+      assert.deepEqual(actualResult.data, result);
+    });
+
+    const withDefault = client.query({ query }).then((actualResult) => {
+      assert.deepEqual(actualResult.data, result);
+    });
+
+    Promise.all([basic, withDefault]).then(res => {
+      done();
+    });
+  });
+
+  it('should allow for a single query with default values that get overridden with variables', (done) => {
+    const query = gql`
+      query people($first: Int = 1) {
+        allPeople(first: $first) {
+          people {
+            name
+          }
+        }
+      }
+    `;
+
+    const variables = { first: 1 };
+    const override = { first: 2 };
+
+    const result = {
+      allPeople: {
+        people: [
+          {
+            name: 'Luke Skywalker',
+          },
+        ],
+      },
+    };
+
+    const overriddenResult = {
+      allPeople: {
+        people: [
+          {
+            name: 'Luke Skywalker',
+          },
+          {
+            name: 'Jabba The Hutt',
+          },
+        ],
+      },
+    };
+
+    const networkInterface = mockNetworkInterface({
+      request: { query, variables },
+      result: { data: result },
+    }, {
+      request: { query, variables: override },
+      result: { data: overriddenResult },
+    });
+
+    const client = new ApolloClient({
+      networkInterface,
+      addTypename: false,
+    });
+
+    const basic = client.query({ query, variables }).then((actualResult) => {
+      assert.deepEqual(actualResult.data, result);
+    });
+
+    const withDefault = client.query({ query }).then((actualResult) => {
+      return assert.deepEqual(actualResult.data, result);
+    });
+
+    const withOverride = client.query({ query, variables: override }).then((actualResult) => {
+      return assert.deepEqual(actualResult.data, overriddenResult);
+    });
+
+    Promise.all([basic, withDefault, withOverride]).then(res => {
+      done();
+    });
   });
 
   it('should allow fragments on root query', () => {
@@ -290,7 +406,7 @@ describe('client', () => {
       ],
     };
 
-    clientRoundrip(query, data);
+    clientRoundtrip(query, data);
   });
 
   it('should allow for a single query with existing store', () => {
@@ -2155,7 +2271,7 @@ describe('client', () => {
   });
 });
 
-function clientRoundrip(
+function clientRoundtrip(
   query: DocumentNode,
   data: ExecutionResult,
   variables?: any,
diff --git a/test/getFromAST.ts b/test/getFromAST.ts
--- a/test/getFromAST.ts
+++ b/test/getFromAST.ts
@@ -5,6 +5,7 @@ import {
   getMutationDefinition,
   createFragmentMap,
   FragmentMap,
+  getDefaultValues,
   getOperationName,
   getFragmentQueryDocument,
 } from '../src/queries/getFromAST';
@@ -332,4 +333,45 @@ describe('AST utility functions', () => {
       );
     });
   });
+
+  describe('getDefaultValues', () => {
+    it('will create an empty variable object if no default values are provided', () => {
+      const basicQuery = gql`
+        query people($first: Int, $second: String) {
+          allPeople(first: $first) {
+            people {
+              name
+            }
+          }
+        }
+      `;
+
+      assert.deepEqual(getDefaultValues(getQueryDefinition(basicQuery)), {});
+    });
+
+    it('will create a variable object based on the definition node with default values', () => {
+      const basicQuery = gql`
+        query people($first: Int = 1, $second: String!) {
+          allPeople(first: $first) {
+            people {
+              name
+            }
+          }
+        }
+      `;
+
+      const complexMutation = gql`
+        mutation complexStuff($test: Input = {key1: ["value", "value2"], key2: {key3: 4}}) {
+          complexStuff(test: $test) {
+            people {
+              name
+            }
+          }
+        }
+      `;
+
+      assert.deepEqual(getDefaultValues(getQueryDefinition(basicQuery)), {first: 1});
+      assert.deepEqual(getDefaultValues(getMutationDefinition(complexMutation)), {test: {key1: ['value', 'value2'], key2: {key3: 4}}});
+    });
+  });
 });
diff --git a/test/graphqlSubscriptions.ts b/test/graphqlSubscriptions.ts
--- a/test/graphqlSubscriptions.ts
+++ b/test/graphqlSubscriptions.ts
@@ -31,6 +31,8 @@ describe('GraphQL Subscriptions', () => {
   let options: any;
   let watchQueryOptions: any;
   let sub2: any;
+  let defaultOptions: any;
+  let defaultSub1: any;
   let commentsQuery: any;
   let commentsVariables: any;
   let commentsSub: any;
@@ -69,6 +71,34 @@ describe('GraphQL Subscriptions', () => {
         },
     };
 
+
+    defaultSub1 = {
+      request: {
+        query: gql`
+          subscription UserInfo($name: String = "Changping Chen") {
+            user(name: $name) {
+              name
+            }
+          }
+        `,
+        variables: {
+          name: 'Changping Chen',
+        },
+      },
+      id: 0,
+      results: [...results],
+    };
+
+    defaultOptions = {
+      query: gql`
+        subscription UserInfo($name: String = "Changping Chen") {
+          user(name: $name) {
+            name
+          }
+        }
+      `,
+    };
+
     watchQueryOptions = {
       query: gql`
         query UserInfo($name: String) {
@@ -145,6 +175,32 @@ describe('GraphQL Subscriptions', () => {
 
 
   it('should start a subscription on network interface and unsubscribe', (done) => {
+    const network = mockSubscriptionNetworkInterface([defaultSub1]);
+    // This test calls directly through Apollo Client
+    const client = new ApolloClient({
+      networkInterface: network,
+      addTypename: false,
+    });
+
+    const sub = client.subscribe(defaultOptions).subscribe({
+      next(result) {
+        assert.deepEqual(result, results[0].result);
+
+        // Test unsubscribing
+        sub.unsubscribe();
+        assert.equal(Object.keys(network.mockedSubscriptionsById).length, 0);
+
+        done();
+      },
+    });
+
+    const id = (sub as any)._networkSubscriptionId;
+    network.fireResult(id);
+
+    assert.equal(Object.keys(network.mockedSubscriptionsById).length, 1);
+  });
+
+  it('should subscribe with default values', (done) => {
     const network = mockSubscriptionNetworkInterface([sub1]);
     // This test calls directly through Apollo Client
     const client = new ApolloClient({
diff --git a/test/mutationResults.ts b/test/mutationResults.ts
--- a/test/mutationResults.ts
+++ b/test/mutationResults.ts
@@ -1209,7 +1209,7 @@ describe('mutation results', () => {
               assert.deepEqual(variables, { a: undefined, b: 2, c: 3 });
               return Promise.resolve({ data: { result: 'goodbye' } });
             case 3:
-              assert.equal(variables, undefined);
+              assert.deepEqual(variables, {});
               return Promise.resolve({ data: { result: 'moon' } });
             default:
               return Promise.reject(new Error('Too many network calls.'));
@@ -1253,6 +1253,61 @@ describe('mutation results', () => {
     }).catch(done);
   });
 
+  it('allows mutations with default values', done => {
+    let count = 0;
+
+    client = new ApolloClient({
+      addTypename: false,
+      networkInterface: {
+        query ({ variables }) {
+          switch (count++) {
+            case 0:
+              assert.deepEqual(variables, { a: 1, b: 'water' });
+              return Promise.resolve({ data: { result: 'hello' } });
+            case 1:
+              assert.deepEqual(variables, { a: 2, b: 'cheese', c: 3 });
+              return Promise.resolve({ data: { result: 'world' } });
+            case 2:
+              assert.deepEqual(variables, { a: 1, b: 'cheese', c: 3 });
+              return Promise.resolve({ data: { result: 'goodbye' } });
+            default:
+              return Promise.reject(new Error('Too many network calls.'));
+          }
+        },
+      },
+    });
+
+    const mutation = gql`
+      mutation ($a: Int = 1, $b: String = "cheese", $c: Int) {
+        result(a: $a, b: $b, c: $c)
+      }
+    `;
+
+    Promise.all([
+      client.mutate({
+        mutation,
+        variables: { a: 1, b: 'water' },
+      }),
+      client.mutate({
+        mutation,
+        variables: { a: 2, c: 3 },
+      }),
+      client.mutate({
+        mutation,
+        variables: { c: 3 },
+      }),
+    ]).then(() => {
+      assert.deepEqual(client.queryManager.getApolloState().data, {
+        ROOT_MUTATION: {
+          'result({"a":1,"b":"water"})': 'hello',
+          'result({"a":2,"b":"cheese","c":3})': 'world',
+          'result({"a":1,"b":"cheese","c":3})': 'goodbye',
+        },
+      });
+      done();
+    }).catch(done);
+  });
+
   it('will pass null to the network interface when provided', done => {
     let count = 0;
 
diff --git a/test/readFromStore.ts b/test/readFromStore.ts
--- a/test/readFromStore.ts
+++ b/test/readFromStore.ts
@@ -111,6 +111,47 @@ describe('reading from the store', () => {
     });
   });
 
+  it('runs a basic query with default values for arguments', () => {
+    const query = gql`
+      query someBigQuery(
+        $stringArg: String = "This is a default string!",
+        $intArg: Int = 0,
+        $floatArg: Float,
+      ){
+        id,
+        stringField(arg: $stringArg),
+        numberField(intArg: $intArg, floatArg: $floatArg),
+        nullField
+      }
+    `;
+
+    const variables = {
+      floatArg: 3.14,
+    };
+
+    const store = {
+      'ROOT_QUERY': {
+        id: 'abcd',
+        nullField: null,
+        'numberField({"intArg":0,"floatArg":3.14})': 5,
+        'stringField({"arg":"This is a default string!"})': 'Heyo',
+      },
+    } as NormalizedCache;
+
+    const result = readQueryFromStore({
+      store,
+      query,
+      variables,
+    });
+
+    assert.deepEqual(result, {
+      id: 'abcd',
+      nullField: null,
+      numberField: 5,
+      stringField: 'Heyo',
+    });
+  });
+
   it('runs a nested query', () => {
     const result: any = {
       id: 'abcd',
diff --git a/test/writeToStore.ts b/test/writeToStore.ts
--- a/test/writeToStore.ts
+++ b/test/writeToStore.ts
@@ -158,6 +158,44 @@ describe('writing to the store', () => {
     });
   });
 
+  it('properly normalizes a query with default values', () => {
+    const query = gql`
+      query someBigQuery($stringArg: String = "This is a default string!", $intArg: Int, $floatArg: Float){
+        id,
+        stringField(arg: $stringArg),
+        numberField(intArg: $intArg, floatArg: $floatArg),
+        nullField
+      }
+    `;
+
+    const variables = {
+      intArg: 5,
+      floatArg: 3.14,
+    };
+
+    const result: any = {
+      id: 'abcd',
+      stringField: 'Heyo',
+      numberField: 5,
+      nullField: null,
+    };
+
+    const normalized = writeQueryToStore({
+      result,
+      query,
+      variables,
+    });
+
+    assert.deepEqual(normalized, {
+      'ROOT_QUERY': {
+        id: 'abcd',
+        nullField: null,
+        'numberField({"intArg":5,"floatArg":3.14})': 5,
+        'stringField({"arg":"This is a default string!"})': 'Heyo',
+      },
+    });
+  });
+
   it('properly normalizes a nested object with an ID', () => {
     const query = gql`
       {