Repository: FormidableLabs/urql
Pull request #12 (2018-01-28 16:47:51)

cache control


Patch:
diff --git a/README.md b/README.md
index 0e42adbd..f11e41b4 100644
--- a/README.md
+++ b/README.md
@@ -168,12 +168,65 @@ The signature of `shouldComponentUpdate` is basically:
 
 Using all or some of these arguments can give you the power to pretty accurately describe whether your connection has now been invalidated.
 
+## Custom Caches
+
+The `Client` constructor accepts a `cache` setting where you can provide your own caching mechanism that will work with `urql`. By default, we use a local object store, but you can provide an adapter for whatever you want.
+
+If you want to supply your own cache, you'll want to provide an object with the following keys:
+
+* `invalidate` - `(hash) => Promise`, invalidates a cache entry.
+* `invalidateAll` - `() => Promise`, basically clears the store.
+* `read` - `(hash) => Promise`, reads and returns a cache entry
+* `update` - `(callback: (store, key, value)) => Promise`, iterates over cache entries and calls the supplied callback function to provide update functionality
+* `write` - `(hash, data) => Promise`, writes a value to the store.
+
+Don't worry about the hashes, we covert query objects(query + variables) to the hash behind the scenes. Here is an example of the cache creation function we use internally for reference:
+
+```javascript
+const defaultCache = store => {
+  return {
+    invalidate: hash =>
+      new Promise(resolve => {
+        delete store[hash];
+        resolve();
+      }),
+    invalidateAll: () =>
+      new Promise(resolve => {
+        store = {};
+        resolve();
+      }),
+    read: hash =>
+      new Promise(resolve => {
+        resolve(store[hash] || null);
+      }),
+    update: callback =>
+      new Promise(resolve => {
+        if (typeof callback === 'function') {
+          Object.keys(store).map(key => {
+            callback(store, key, store[key]);
+          });
+        }
+        resolve();
+      }),
+    write: (hash, data) =>
+      new Promise(resolve => {
+        store[hash] = data;
+        resolve();
+      }),
+  };
+};
+```
+
 ## API
 
-### `Client({url: string, fetchOptions?: object | () => object})`
+### `Client({url: string, initialCache?: object, cache?: Cache, fetchOptions?: object | () => object})`
 
 Client is the constructor for your GraphQL client. It takes a configuration object as an argument, which is required. Providing a GraphQL api url via the `url` property is required. `fetchOptions` are the options provided to internal `fetch` calls, which can either be in `object` format, or a `function` that returns an `object`, in case you want to provide a dynamic header for a token or something.
 
+`initialCache` is an initial state for your cache if you are using the default cache. This probably won't get much play until SSR is implemented.
+
+`cache` accepts an instance of a cache, if you want to build your own custom one built with something like `AsyncStorage`. You can read more about how to create one of these above.
+
 Example:
 
 ```javascript
@@ -217,12 +270,20 @@ The following fields are present on the render functions argument object:
 
 | Name     | Value      | Default    | Description                                                     |
 | -------- | ---------- | ---------- | --------------------------------------------------------------- |
+| cache    | `object`   | Cache      | Provides cache operations, defined below                        |
 | fetching | `boolean`  | `false`    | Fetching is true during any pending query or mutation operation |
 | loaded   | `boolean`  | `false`    | Becomes true once the component gets data for the first time.   |
 | error    | `object`   | `null`     | Any errors thrown during a query or mutation                    |
 | data     | `object`   | `null`     | Any data returned as the result of a query                      |
 | refetch  | `function` | `function` | Function used to refetch existing queries from the server       |
 
+The `cache` object provides several helpful cache methods that you can use to control the cache:
+
+* `invalidate` - `invalidate` takes an optional QueryObject parameter, but defaults to invalidating the queries defined on the component.
+* `invalidateAll` - Basically clears your entire cache.
+* `read` - Takes a QueryObject parameter, returns cache value for that query.
+* `update` - Takes a callback function with an argument shape of `(store, key, value)`. The callback function is run against every cache entry, giving you the opportunity to update any given value based upon the context of the current data shape.
+
 In addition to these, any specified mutations are also provided as their key in the mutation map. Mutations are functions that accept an object of variables as an argument.
 
 Example:
@@ -305,9 +366,6 @@ mutation($id: ID!) {
 
 * [ ] Server Side Rendering
 * [ ] Client Side Resolvers
-* [ ] External cache hooks
-* [ ] Invalidation hooks
-* [ ] Cache updates without server
 * [ ] Prefix all errors with "Did I do that?"
 
 ## Prior Art
diff --git a/example/src/app/home.tsx b/example/src/app/home.tsx
index 5f8ecf63..6e31c02d 100644
--- a/example/src/app/home.tsx
+++ b/example/src/app/home.tsx
@@ -1,3 +1,5 @@
+/* tslint:disable */
+
 import React from 'react';
 import { Connect, query, mutation } from '../../../src/index';
 import TodoList from './todo-list';
@@ -11,7 +13,7 @@ const Home = () => (
       addTodo: mutation(AddTodo),
       removeTodo: mutation(RemoveTodo),
     }}
-    render={({ loaded, data, addTodo, removeTodo }) => {
+    render={({ loaded, data, addTodo, removeTodo, refetch }) => {
       return (
         <div>
           {!loaded ? (
@@ -20,6 +22,15 @@ const Home = () => (
             <TodoList todos={data.todos} removeTodo={removeTodo} />
           )}
           <TodoForm addTodo={addTodo} />
+          <button type="button" onClick={refetch}>
+            Refetch
+          </button>
+          <button
+            type="button"
+            onClick={refetch.bind(null, { skipCache: true })}
+          >
+            Refetch (Skip Cache)
+          </button>
         </div>
       );
     }}
diff --git a/example/src/server/schema.js b/example/src/server/schema.js
index 5ad92dd0..39eae4b4 100644
--- a/example/src/server/schema.js
+++ b/example/src/server/schema.js
@@ -47,11 +47,7 @@ const typeDefs = `
 const resolvers = {
   Query: {
     todos: (root, args, context) => {
-      return new Promise(resolve => {
-        setTimeout(() => {
-          resolve(store.todos);
-        }, 500);
-      });
+      return store.todos;
     },
     todo: (root, args, context) => {
       return store.todos.find(a => (a.id = args.id));
@@ -65,15 +61,11 @@ const resolvers = {
       const id = uuid();
       const { text } = args;
       store.todos.push({ id, text });
-      return new Promise(resolve => {
-        setTimeout(() => {
-          resolve({ id, text });
-        }, 500);
-      });
+      return { id, text };
     },
     removeTodo: (root, args, context) => {
       const { id } = args;
-      let todo = store.todos.some(todo => todo.id === id);
+      let todo = store.todos.find(todo => todo.id === id);
       store.todos.splice(store.todos.indexOf(todo), 1);
       return { id };
     },
diff --git a/src/components/client.tsx b/src/components/client.tsx
index 68dff3d3..e5f297a2 100644
--- a/src/components/client.tsx
+++ b/src/components/client.tsx
@@ -1,5 +1,6 @@
 import { Component, ReactNode } from 'react';
 import { IClient, IMutation, IQuery } from '../interfaces/index';
+import { hashString } from '../modules/hash';
 import { formatTypeNames } from '../modules/typenames';
 
 export interface IClientProps {
@@ -64,6 +65,37 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
     this.props.client.unsubscribe(this.subscriptionID);
   }
 
+  invalidate = queryObject => {
+    const { cache } = this.props.client;
+    if (queryObject) {
+      const stringified = JSON.stringify(formatTypeNames(queryObject));
+      const hash = hashString(stringified);
+      cache.invalidate(hash);
+    } else {
+      return Array.isArray(this.props.query)
+        ? Promise.all(
+            this.props.query.map(q =>
+              cache.invalidate(hashString(JSON.stringify(q)))
+            )
+          )
+        : cache.invalidate(hashString(JSON.stringify(this.query)));
+    }
+  };
+
+  invalidateAll = () => {
+    return this.props.client.cache.invalidateAll();
+  };
+
+  read = query => {
+    const stringified = JSON.stringify(formatTypeNames(query));
+    const hash = hashString(stringified);
+    return this.props.client.cache.read(hash);
+  };
+
+  updateCache = callback => {
+    return this.props.client.cache.update(callback);
+  };
+
   formatProps = props => {
     // If query exists
     if (props.query) {
@@ -240,10 +272,18 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
   };
 
   render() {
+    const cache = {
+      invalidate: this.invalidate,
+      invalidateAll: this.invalidateAll,
+      read: this.read,
+      update: this.updateCache,
+    };
+
     return this.props.render
       ? this.props.render({
           ...this.state,
           ...this.mutations,
+          cache,
           refetch: this.fetch,
         })
       : null;
diff --git a/src/interfaces/cache.ts b/src/interfaces/cache.ts
new file mode 100644
index 00000000..a2709cc6
--- /dev/null
+++ b/src/interfaces/cache.ts
@@ -0,0 +1,7 @@
+export interface ICache {
+  write: (query: string, data: any) => Promise<any>;
+  read: (query: string) => Promise<any>;
+  invalidate: (query: string) => Promise<any>;
+  invalidateAll: () => Promise<any>;
+  update: (callback: () => void) => Promise<any>;
+}
diff --git a/src/interfaces/client-options.ts b/src/interfaces/client-options.ts
index 076bc7bb..d7e44542 100644
--- a/src/interfaces/client-options.ts
+++ b/src/interfaces/client-options.ts
@@ -1,4 +1,8 @@
+import { ICache } from './cache';
+
 export interface IClientOptions {
   url: string;
   fetchOptions?: object | (() => object);
+  cache?: ICache;
+  initialCache?: object;
 }
diff --git a/src/interfaces/client.ts b/src/interfaces/client.ts
index f1e90831..ffd2fdda 100644
--- a/src/interfaces/client.ts
+++ b/src/interfaces/client.ts
@@ -1,8 +1,10 @@
 import { IQueryResponse } from './../modules/client';
+import { ICache } from './cache';
 import { IMutation } from './mutation';
 import { IQuery } from './query';
 
 export interface IClient {
+  cache: ICache;
   executeQuery(
     queryObject: IQuery,
     skipCache: boolean
diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts
index 4a08f44b..3fcf4440 100644
--- a/src/interfaces/index.ts
+++ b/src/interfaces/index.ts
@@ -2,3 +2,4 @@ export { IClient } from './client';
 export { IMutation } from './mutation';
 export { IQuery } from './query';
 export { IClientOptions } from './client-options';
+export { ICache } from './cache';
diff --git a/src/modules/client.ts b/src/modules/client.ts
index 092c8f46..27d54e50 100644
--- a/src/modules/client.ts
+++ b/src/modules/client.ts
@@ -1,6 +1,6 @@
 import uuid from 'uuid';
 
-import { IClientOptions, IMutation, IQuery } from '../interfaces/index';
+import { ICache, IClientOptions, IMutation, IQuery } from '../interfaces/index';
 import { gankTypeNamesFromResponse } from '../modules/typenames';
 import { hashString } from './hash';
 
@@ -10,11 +10,45 @@ export interface IQueryResponse {
   typeNames?: string[];
 }
 
+const defaultCache = store => {
+  return {
+    invalidate: hash =>
+      new Promise(resolve => {
+        delete store[hash];
+        resolve();
+      }),
+    invalidateAll: () =>
+      new Promise(resolve => {
+        store = {};
+        resolve();
+      }),
+    read: hash =>
+      new Promise(resolve => {
+        resolve(store[hash] || null);
+      }),
+    update: callback =>
+      new Promise(resolve => {
+        if (typeof callback === 'function') {
+          Object.keys(store).map(key => {
+            callback(store, key, store[key]);
+          });
+        }
+        resolve();
+      }),
+    write: (hash, data) =>
+      new Promise(resolve => {
+        store[hash] = data;
+        resolve();
+      }),
+  };
+};
+
 export default class Client {
   url?: string; // Graphql API URL
   store: object; // Internal store
   fetchOptions: object | (() => object); // Options for fetch call
   subscriptions: object; // Map of subscribed Connect components
+  cache: ICache; // Cache object
 
   constructor(opts?: IClientOptions) {
     if (!opts) {
@@ -24,9 +58,11 @@ export default class Client {
     if (!opts.url) {
       throw new Error('Please provide a URL for your GraphQL API');
     }
+
     this.url = opts.url;
     this.fetchOptions = opts.fetchOptions || {};
-    this.store = {};
+    this.store = opts.initialCache || {};
+    this.cache = opts.cache || defaultCache(this.store);
     this.subscriptions = {};
     // Bind methods
     this.executeQuery = this.executeQuery.bind(this);
@@ -73,42 +109,44 @@ export default class Client {
       const hash = hashString(body);
 
       // Check cache for hash
-      if (this.store[hash] && !skipCache) {
-        const typeNames = gankTypeNamesFromResponse(this.store[hash]);
-        resolve({ data: this.store[hash], typeNames });
-      } else {
-        const fetchOptions =
-          typeof this.fetchOptions === 'function'
-            ? this.fetchOptions()
-            : this.fetchOptions;
-        // Fetch data
-        fetch(this.url, {
-          body,
-          headers: { 'Content-Type': 'application/json' },
-          method: 'POST',
-          ...fetchOptions,
-        })
-          .then(res => res.json())
-          .then(response => {
-            if (response.data) {
-              // Grab typenames from response data
-              const typeNames = gankTypeNamesFromResponse(response.data);
-              // Store data in cache, using serialized query as key
-              this.store[hash] = response.data;
-              resolve({
-                data: response.data,
-                typeNames,
-              });
-            } else {
-              reject({
-                message: 'No data',
-              });
-            }
+      this.cache.read(hash).then(data => {
+        if (data && !skipCache) {
+          const typeNames = gankTypeNamesFromResponse(data);
+          resolve({ data, typeNames });
+        } else {
+          const fetchOptions =
+            typeof this.fetchOptions === 'function'
+              ? this.fetchOptions()
+              : this.fetchOptions;
+          // Fetch data
+          fetch(this.url, {
+            body,
+            headers: { 'Content-Type': 'application/json' },
+            method: 'POST',
+            ...fetchOptions,
           })
-          .catch(e => {
-            reject(e);
-          });
-      }
+            .then(res => res.json())
+            .then(response => {
+              if (response.data) {
+                // Grab typenames from response data
+                const typeNames = gankTypeNamesFromResponse(response.data);
+                // Store data in cache, using serialized query as key
+                this.cache.write(hash, response.data);
+                resolve({
+                  data: response.data,
+                  typeNames,
+                });
+              } else {
+                reject({
+                  message: 'No data',
+                });
+              }
+            })
+            .catch(e => {
+              reject(e);
+            });
+        }
+      });
     });
   }
 
diff --git a/tslint.json b/tslint.json
index 7c64df77..3f480a8f 100644
--- a/tslint.json
+++ b/tslint.json
@@ -1,7 +1,7 @@
 {
   "extends": ["tslint:latest", "tslint-react", "tslint-config-prettier"],
   "linterOptions": {
-    "exclude": ["src/tests/**/*"]
+    "exclude": ["src/tests/**/*", "example/**/*"]
   },
   "rules": {
     "member-access": false


Test patch:
diff --git a/src/tests/components/__snapshots__/connect-hoc.test.tsx.snap b/src/tests/components/__snapshots__/connect-hoc.test.tsx.snap
index 354cea75..0a9a2825 100644
--- a/src/tests/components/__snapshots__/connect-hoc.test.tsx.snap
+++ b/src/tests/components/__snapshots__/connect-hoc.test.tsx.snap
@@ -2,6 +2,14 @@
 
 exports[`Connect HOC should wrap its component argument with connect 1`] = `
 <div
+  cache={
+    Object {
+      "invalidate": [Function],
+      "invalidateAll": [Function],
+      "read": [Function],
+      "update": [Function],
+    }
+  }
   data={null}
   error={null}
   fetching={false}
diff --git a/src/tests/components/__snapshots__/connect.test.tsx.snap b/src/tests/components/__snapshots__/connect.test.tsx.snap
index 1281e60a..fd4e28dd 100644
--- a/src/tests/components/__snapshots__/connect.test.tsx.snap
+++ b/src/tests/components/__snapshots__/connect.test.tsx.snap
@@ -2,6 +2,14 @@
 
 exports[`Client Component should provide a context consumer and pass through to client 1`] = `
 <div
+  cache={
+    Object {
+      "invalidate": [Function],
+      "invalidateAll": [Function],
+      "read": [Function],
+      "update": [Function],
+    }
+  }
   data={null}
   error={null}
   fetching={false}