Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

(graphcache) Validation for Graphcache's opts.updates, opts.resolvers, opts.optimistic (#826)

* Ensure console mocks are reset after every test, without having to explicity do so.

* Graphcache: Wrap all `opts` validation in `if (process.env.NODE_ENV !== 'production')`.

* Graphcache: console.warn() if creating a store and updates has invalid mutations/subscriptions.

* Graphcache: console.warn() if creating a store with invalid resolvers.

* Graphcache: console.warn() if creating a store with invalid optimistic mutations.

* Graphcache: expand tests for query() and write().

* Graphcache: update changeset for new console.warn().

Amy Boyd f12b477e 98b6edc2

Changed files
+481 -16
.changeset
docs
graphcache
exchanges
scripts
+3 -2
.changeset/thick-avocados-enjoy.md
···
'@urql/exchange-graphcache': minor
---
-
Issue warnings when an unknown type has been included in Graphcache's opts.key configuration to help spot typos.
-
See: [#820](https://github.com/FormidableLabs/urql/pull/820)
+
Issue warnings when an unknown type or field has been included in Graphcache's `opts` configuration to help spot typos.
+
Checks `opts.keys`, `opts.updates`, `opts.resolvers` and `opts.optimistic`.
+
See: [#820](https://github.com/FormidableLabs/urql/pull/820) and [#826](https://github.com/FormidableLabs/urql/pull/826)
+43
docs/graphcache/errors.md
···
Check whether your schema is up-to-date, or whether you're using an invalid
typename in `opts.keys`, maybe due to a typo.
+
+
## (21) Invalid mutation
+
+
> Invalid mutation field `???` is not in the defined schema but the `updates` option is referencing it.
+
+
When you're passing an introspected schema to the cache exchange, it is
+
able to check whether your `opts.updates.Mutation` is valid.
+
This error occurs when an unknown mutation field is found in `opts.updates.Mutation`.
+
+
Check whether your schema is up-to-date, or whether you've got a typo in `opts.updates.Mutation`.
+
+
## (22) Invalid subscription
+
+
> Invalid subscription field: `???` is not in the defined schema but the `updates` option is referencing it.
+
+
When you're passing an introspected schema to the cache exchange, it is
+
able to check whether your `opts.updates.Subscription` is valid.
+
This error occurs when an unknown subscription field is found in `opts.updates.Subscription`.
+
+
Check whether your schema is up-to-date, or whether you're using an invalid
+
subscription name in `opts.updates.Subscription`, maybe due to a typo.
+
+
## (23) Invalid resolver
+
+
> Invalid resolver: `???` is not in the defined schema, but the `resolvers`
+
> option is referencing it.
+
+
When you're passing an introspected schema to the cache exchange, it is
+
able to check whether your `opts.resolvers` is valid.
+
This error occurs when an unknown query, type or field is found in `opts.resolvers`.
+
+
Check whether your schema is up-to-date, or whether you've got a typo in `opts.resolvers`.
+
+
## (24) Invalid optimistic mutation
+
+
> Invalid optimistic mutation field: `???` is not a mutation field in the defined schema,
+
> but the `optimistic` option is referencing it.
+
+
When you're passing an introspected schema to the cache exchange, it is
+
able to check whether your `opts.optimistic` is valid.
+
This error occurs when a field in `opts.optimistic` is not in the schema's `Mutation` fields.
+
+
Check whether your schema is up-to-date, or whether you've got a typo in `Mutation` or `opts.optimistic`.
+124 -1
exchanges/graphcache/src/ast/schemaPredicates.ts
···
} from 'graphql';
import { warn, invariant } from '../helpers/help';
-
import { KeyingConfig } from '../types';
+
import {
+
KeyingConfig,
+
UpdatesConfig,
+
ResolverConfig,
+
OptimisticMutationConfig,
+
} from '../types';
export const isFieldNullable = (
schema: GraphQLSchema,
···
});
}
}
+
+
export function expectValidUpdatesConfig(
+
schema: GraphQLSchema,
+
updates: UpdatesConfig
+
): void {
+
if (process.env.NODE_ENV === 'production') {
+
return;
+
}
+
+
/* eslint-disable prettier/prettier */
+
const schemaMutations = schema.getMutationType()
+
? Object.keys((schema.getMutationType() as GraphQLObjectType).toConfig().fields)
+
: [];
+
const schemaSubscriptions = schema.getSubscriptionType()
+
? Object.keys((schema.getSubscriptionType() as GraphQLObjectType).toConfig().fields)
+
: [];
+
const givenMutations = updates.Mutation
+
? Object.keys(updates.Mutation)
+
: [];
+
const givenSubscriptions = updates.Subscription
+
? Object.keys(updates.Subscription)
+
: [];
+
/* eslint-enable prettier/prettier */
+
+
for (const givenMutation of givenMutations) {
+
if (schemaMutations.indexOf(givenMutation) === -1) {
+
warn(
+
'Invalid mutation field: `' +
+
givenMutation +
+
'` is not in the defined schema, but the `updates.Mutation` option is referencing it.',
+
21
+
);
+
}
+
}
+
+
for (const givenSubscription of givenSubscriptions) {
+
if (schemaSubscriptions.indexOf(givenSubscription) === -1) {
+
warn(
+
'Invalid subscription field: `' +
+
givenSubscription +
+
'` is not in the defined schema, but the `updates.Subscription` option is referencing it.',
+
22
+
);
+
}
+
}
+
}
+
+
function warnAboutResolver(name: string): void {
+
warn(
+
`Invalid resolver: \`${name}\` is not in the defined schema, but the \`resolvers\` option is referencing it.`,
+
23
+
);
+
}
+
+
export function expectValidResolversConfig(
+
schema: GraphQLSchema,
+
resolvers: ResolverConfig
+
): void {
+
if (process.env.NODE_ENV === 'production') {
+
return;
+
}
+
+
const validTypes = Object.keys(schema.getTypeMap());
+
+
for (const key in resolvers) {
+
if (key === 'Query') {
+
const queryType = schema.getQueryType();
+
if (queryType) {
+
const validQueries = Object.keys(queryType.toConfig().fields);
+
for (const resolverQuery in resolvers.Query) {
+
if (validQueries.indexOf(resolverQuery) === -1) {
+
warnAboutResolver('Query.' + resolverQuery);
+
}
+
}
+
} else {
+
warnAboutResolver('Query');
+
}
+
} else {
+
if (validTypes.indexOf(key) === -1) {
+
warnAboutResolver(key);
+
} else {
+
const validTypeProperties = Object.keys(
+
(schema.getType(key) as GraphQLObjectType).getFields()
+
);
+
const resolverProperties = Object.keys(resolvers[key]);
+
for (const resolverProperty of resolverProperties) {
+
if (validTypeProperties.indexOf(resolverProperty) === -1) {
+
warnAboutResolver(key + '.' + resolverProperty);
+
}
+
}
+
}
+
}
+
}
+
}
+
+
export function expectValidOptimisticMutationsConfig(
+
schema: GraphQLSchema,
+
optimisticMutations: OptimisticMutationConfig
+
): void {
+
if (process.env.NODE_ENV === 'production') {
+
return;
+
}
+
+
const validMutations = schema.getMutationType()
+
? Object.keys(
+
(schema.getMutationType() as GraphQLObjectType).toConfig().fields
+
)
+
: [];
+
+
for (const mutation in optimisticMutations) {
+
if (validMutations.indexOf(mutation) === -1) {
+
warn(
+
`Invalid optimistic mutation field: \`${mutation}\` is not a mutation field in the defined schema, but the \`optimistic\` option is referencing it.`,
+
24
+
);
+
}
+
}
+
}
+5 -1
exchanges/graphcache/src/helpers/help.ts
···
| 17
| 18
| 19
-
| 20;
+
| 20
+
| 21
+
| 22
+
| 23
+
| 24;
type DebugNode = ExecutableDefinitionNode | InlineFragmentNode;
+9 -2
exchanges/graphcache/src/operations/query.test.ts
···
],
}
);
-
-
jest.resetAllMocks();
});
it('test partial results', () => {
···
__typename: 'Query',
todos: [{ __typename: 'Todo', id: '0', text: 'Solve bug' }],
});
+
+
expect(console.warn).not.toHaveBeenCalled();
+
expect(console.error).not.toHaveBeenCalled();
});
it('should respect altered root types', () => {
···
__typename: 'query_root',
todos: [{ __typename: 'Todo', id: '0', text: 'Solve bug' }],
});
+
+
expect(console.warn).not.toHaveBeenCalled();
+
expect(console.error).not.toHaveBeenCalled();
});
it('should allow subsequent read when first result was null', () => {
···
__typename: 'query_root',
todos: [null],
});
+
+
expect(console.warn).not.toHaveBeenCalled();
+
expect(console.error).not.toHaveBeenCalled();
});
});
+36 -3
exchanges/graphcache/src/operations/write.test.ts
···
jest.clearAllMocks();
});
+
it('should not crash for valid writes', async () => {
+
const VALID_TODO_QUERY = gql`
+
mutation {
+
toggleTodo {
+
id
+
text
+
complete
+
}
+
}
+
`;
+
write(
+
store,
+
{ query: VALID_TODO_QUERY },
+
{
+
__typename: 'Mutation',
+
toggleTodo: {
+
__typename: 'Todo',
+
id: '0',
+
text: 'Teach',
+
complete: true,
+
},
+
}
+
);
+
expect(console.warn).not.toHaveBeenCalled();
+
expect(console.error).not.toHaveBeenCalled();
+
});
+
it('should warn once for invalid fields on an entity', () => {
const INVALID_TODO_QUERY = gql`
mutation {
···
}
);
expect(console.warn).toHaveBeenCalledTimes(1);
-
expect((console.warn as any).mock.calls[0][0]).toMatch(/incomplete/);
+
expect((console.warn as any).mock.calls[0][0]).toMatch(
+
/The field `incomplete` does not exist on `Todo`/
+
);
});
it('should warn once for invalid fields on an entity', () => {
···
);
expect(console.warn).toHaveBeenCalledTimes(1);
-
expect((console.warn as any).mock.calls[0][0]).toMatch(/writer/);
+
expect((console.warn as any).mock.calls[0][0]).toMatch(
+
/The field `writer` does not exist on `Todo`/
+
);
});
it('should skip undefined values that are expected', () => {
···
write(store, { query }, { field: undefined } as any);
// Because of us writing an undefined field
expect(console.warn).toHaveBeenCalledTimes(2);
-
expect((console.warn as any).mock.calls[0][0]).toMatch(/undefined/);
+
expect((console.warn as any).mock.calls[0][0]).toMatch(
+
/The field `field` does not exist on `Query`/
+
);
InMemoryData.initDataState(store.data, null);
// The field must still be `'test'`
+207 -2
exchanges/graphcache/src/store/store.test.ts
···
import { write, writeOptimistic } from '../operations/write';
import * as InMemoryData from './data';
import { Store } from './store';
+
import { noop } from '../test-utils/utils';
const Appointment = gql`
query appointment($id: String) {
···
});
});
+
describe('Store with UpdatesConfig', () => {
+
it("sets the store's updates field to the given argument", () => {
+
const updatesOption = {
+
Mutation: {
+
toggleTodo: noop,
+
},
+
Subscription: {
+
newTodo: noop,
+
},
+
};
+
+
const store = new Store({
+
updates: updatesOption,
+
});
+
+
expect(store.updates.Mutation).toBe(updatesOption.Mutation);
+
expect(store.updates.Subscription).toBe(updatesOption.Subscription);
+
});
+
+
it("sets the store's updates field to an empty default if not provided", () => {
+
const store = new Store({});
+
+
expect(store.updates.Mutation).toEqual({});
+
expect(store.updates.Subscription).toEqual({});
+
});
+
+
it('should not warn if Mutation/Subscription operations do exist in the schema', function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
updates: {
+
Mutation: {
+
toggleTodo: noop,
+
},
+
Subscription: {
+
newTodo: noop,
+
},
+
},
+
});
+
+
expect(console.warn).not.toBeCalled();
+
});
+
+
it("should warn if Mutation operations don't exist in the schema", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
updates: {
+
Mutation: {
+
doTheChaChaSlide: noop,
+
},
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid mutation field: `doTheChaChaSlide` is not in the defined schema, but the `updates.Mutation` option is referencing it.'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#21');
+
});
+
+
it("should warn if Subscription operations don't exist in the schema", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
updates: {
+
Subscription: {
+
someoneDidTheChaChaSlide: noop,
+
},
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid subscription field: `someoneDidTheChaChaSlide` is not in the defined schema, but the `updates.Subscription` option is referencing it.'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#22');
+
});
+
});
+
describe('Store with KeyingConfig', () => {
it('generates keys from custom keying function', () => {
const store = new Store({
···
});
});
+
describe('Store with ResolverConfig', () => {
+
it("sets the store's resolvers field to the given argument", () => {
+
const resolversOption = {
+
Query: {
+
latestTodo: () => 'todo',
+
},
+
};
+
+
const store = new Store({
+
resolvers: resolversOption,
+
});
+
+
expect(store.resolvers).toBe(resolversOption);
+
});
+
+
it("sets the store's resolvers field to an empty default if not provided", () => {
+
const store = new Store({});
+
+
expect(store.resolvers).toEqual({});
+
});
+
+
it('should not warn if resolvers do exist in the schema', function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
resolvers: {
+
Query: {
+
latestTodo: () => 'todo',
+
todos: () => ['todo 1', 'todo 2'],
+
},
+
Todo: {
+
text: todo => (todo.text as string).toUpperCase(),
+
author: todo => (todo.author as string).toUpperCase(),
+
},
+
},
+
});
+
+
expect(console.warn).not.toBeCalled();
+
});
+
+
it("should warn if a Query doesn't exist in the schema", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
resolvers: {
+
Query: {
+
todos: () => ['todo 1', 'todo 2'],
+
// This query should be warned about.
+
findDeletedTodos: () => ['todo 1', 'todo 2'],
+
},
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid resolver: `Query.findDeletedTodos` is not in the defined schema, but the `resolvers` option is referencing it'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#23');
+
});
+
+
it("should warn if a type doesn't exist in the schema", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
resolvers: {
+
Todo: {
+
complete: () => true,
+
},
+
// This type should be warned about.
+
Dinosaur: {
+
isExtinct: () => true,
+
},
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid resolver: `Dinosaur` is not in the defined schema, but the `resolvers` option is referencing it'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#23');
+
});
+
+
it("should warn if a type's property doesn't exist in the schema", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
resolvers: {
+
Todo: {
+
complete: () => true,
+
// This property should be warned about.
+
isAboutDinosaurs: () => true,
+
},
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid resolver: `Todo.isAboutDinosaurs` is not in the defined schema, but the `resolvers` option is referencing it'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#23');
+
});
+
});
+
describe('Store with OptimisticMutationConfig', () => {
let store;
···
InMemoryData.initDataState(store.data, null);
});
-
it('Should resolve a property', () => {
+
it('should resolve a property', () => {
const todoResult = store.resolve({ __typename: 'Todo', id: '0' }, 'text');
expect(todoResult).toEqual('Go to the shops');
const authorResult = store.resolve(
···
InMemoryData.clearDataState();
});
-
it('Should resolve a link property', () => {
+
it('should resolve a link property', () => {
const parent = {
id: '0',
text: 'test',
···
InMemoryData.initDataState(store.data, null);
expect(InMemoryData.readRecord('Query', 'base')).toBe(true);
InMemoryData.clearDataState();
+
});
+
+
it("should warn if an optimistic field doesn't exist in the schema's mutations", function () {
+
new Store({
+
schema: require('../test-utils/simple_schema.json'),
+
updates: {
+
Mutation: {
+
toggleTodo: noop,
+
},
+
},
+
optimistic: {
+
toggleTodo: () => null,
+
// This field should be warned about.
+
deleteTodo: () => null,
+
},
+
});
+
+
expect(console.warn).toBeCalledTimes(1);
+
const warnMessage = mocked(console.warn).mock.calls[0][0];
+
expect(warnMessage).toContain(
+
'Invalid optimistic mutation field: `deleteTodo` is not a mutation field in the defined schema, but the `optimistic` option is referencing it.'
+
);
+
expect(warnMessage).toContain('https://bit.ly/2XbVrpR#24');
});
});
+25 -2
exchanges/graphcache/src/store/store.ts
···
if (mutationType) mutationName = mutationType.name;
if (subscriptionType) subscriptionName = subscriptionType.name;
-
if (this.keys) {
-
SchemaPredicates.expectValidKeyingConfig(this.schema, this.keys);
+
if (process.env.NODE_ENV !== 'production') {
+
if (this.keys) {
+
SchemaPredicates.expectValidKeyingConfig(this.schema, this.keys);
+
}
+
+
const hasUpdates =
+
Object.keys(this.updates.Mutation).length > 0 ||
+
Object.keys(this.updates.Subscription).length > 0;
+
if (hasUpdates) {
+
SchemaPredicates.expectValidUpdatesConfig(this.schema, this.updates);
+
}
+
+
if (this.resolvers) {
+
SchemaPredicates.expectValidResolversConfig(
+
this.schema,
+
this.resolvers
+
);
+
}
+
+
if (this.optimisticMutations) {
+
SchemaPredicates.expectValidOptimisticMutationsConfig(
+
this.schema,
+
this.optimisticMutations
+
);
+
}
}
}
+24 -1
exchanges/graphcache/src/test-utils/simple_schema.json
···
"mutationType": {
"name": "Mutation"
},
-
"subscriptionType": null,
+
"subscriptionType": {
+
"name": "Subscription"
+
},
"types": [
{
"kind": "OBJECT",
···
"name": "Todo",
"ofType": null
}
+
}
+
],
+
"inputFields": null,
+
"interfaces": [],
+
"enumValues": null,
+
"possibleTypes": null
+
},
+
{
+
"kind": "OBJECT",
+
"name": "Subscription",
+
"fields": [
+
{
+
"name": "newTodo",
+
"args": [],
+
"type": {
+
"kind": "OBJECT",
+
"name": "Todo",
+
"ofType": null
+
},
+
"isDeprecated": false,
+
"deprecationReason": null
}
],
"inputFields": null,
+2
exchanges/graphcache/src/test-utils/utils.ts
···
+
// eslint-disable-next-line
+
export const noop = () => {};
+1
scripts/jest/preset.js
···
setupFiles: [
require.resolve('./setup.js')
],
+
clearMocks: true,
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
+2 -2
scripts/jest/setup.js
···
+
// This script is run before each `.test.ts` file.
+
global.AbortController = undefined;
global.fetch = jest.fn();
process.on('unhandledRejection', error => {
throw error;
});
-
-
jest.restoreAllMocks();
const originalConsole = console;
global.console = {